session-orchestrator 5.12.0 → 5.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +4 -1
- package/.codex-plugin/agents/explorer.toml +1 -1
- package/.codex-plugin/agents/session-reviewer.toml +2 -2
- package/.codex-plugin/agents/wave-worker.toml +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +8 -0
- package/.orchestrator/policy/public-export.json +30 -0
- package/CHANGELOG.md +162 -4
- package/README.md +126 -127
- package/agents/analyst.md +7 -2
- package/agents/architect-reviewer.md +7 -2
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +9 -2
- package/agents/dialectic-deriver.md +9 -3
- package/agents/docs-writer.md +7 -2
- package/agents/eval-judge.md +7 -1
- package/agents/qa-strategist.md +7 -2
- package/agents/security-reviewer.md +9 -2
- package/agents/session-reviewer.md +18 -2
- package/agents/skill-applied-judge.md +7 -1
- package/agents/test-writer.md +9 -2
- package/agents/ui-developer.md +9 -2
- package/agents/ux-evaluator.md +6 -1
- package/commands/session.md +1 -1
- package/commands/templates-ack.md +89 -18
- package/docs/agent-authoring.md +30 -11
- package/docs/ci-setup.md +38 -2
- package/docs/codex-setup.md +33 -9
- package/docs/components.md +13 -2
- package/docs/cursor-setup.md +3 -3
- package/docs/events-schema.md +5 -4
- package/docs/evidence-candidates.md +63 -0
- package/docs/fleet-checkin-audit.md +7 -4
- package/docs/github-mirror-protection.md +150 -3
- package/docs/install.md +90 -17
- package/docs/instruction-delivery.md +1 -1
- package/docs/migration-v4.md +2 -2
- package/docs/mods.md +118 -0
- package/docs/owner-config-schema.md +1 -0
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/pm-skills-marketplace.md +4 -3
- package/docs/rule-authoring.md +24 -13
- package/docs/scope-collision-guard.md +2 -1
- package/docs/session-config-reference.md +49 -15
- package/docs/session-config-template.md +30 -22
- package/docs/sessions-ledger-repair.md +137 -0
- package/docs/vault-docs-architecture.md +1 -1
- package/hooks/_lib/hook-import-set.json +406 -73
- package/hooks/_lib/hook-root.mjs +301 -0
- package/hooks/_lib/lock-bootstrap.mjs +434 -4
- package/hooks/_lib/orchestrator-ignore.mjs +3 -0
- package/hooks/_lib/subagent-transcript.mjs +2 -2
- package/hooks/_lib/vcs-create-matcher.mjs +13 -17
- package/hooks/cwd-change-restore.mjs +5 -1
- package/hooks/enforce-commands.mjs +4 -3
- package/hooks/enforce-scope.mjs +11 -1
- package/hooks/hooks-codex.json +48 -2
- package/hooks/hooks.json +31 -31
- package/hooks/on-session-end.mjs +38 -15
- package/hooks/on-session-start.mjs +139 -49
- package/hooks/on-stop.mjs +11 -6
- package/hooks/post-bash-issue-budget-refund.mjs +6 -1
- package/hooks/post-bash-write-verify.mjs +8 -4
- package/hooks/post-edit-validate.mjs +45 -52
- package/hooks/post-subagent-discovery-validator.mjs +2 -2
- package/hooks/post-tool-batch-wave-signal.mjs +8 -4
- package/hooks/post-tool-failure-corrective-context.mjs +5 -1
- package/hooks/pre-bash-destructive-guard.mjs +220 -8
- package/hooks/pre-bash-issue-budget.mjs +6 -1
- package/hooks/pre-bash-sessions-ledger-guard.mjs +691 -123
- package/hooks/pre-bash-staging-fence.mjs +258 -14
- package/hooks/pre-bash-templates-first.mjs +28 -27
- package/hooks/run-node.sh +1 -1
- package/hooks/skill-invocation-telemetry.mjs +15 -8
- package/hooks/subagent-telemetry.mjs +77 -14
- package/hooks/wave-scope-commit-guard.mjs +143 -14
- package/mods/so-hud/.claude-plugin/plugin.json +11 -0
- package/mods/so-hud/README.md +57 -0
- package/mods/so-hud/hooks/beacon-io.mjs +101 -0
- package/mods/so-hud/hooks/heredoc-scan.ts +394 -0
- package/mods/so-hud/hooks/hooks.json +1 -0
- package/mods/so-hud/hooks/logic.ts +182 -0
- package/mods/so-hud/hooks/register.tsx +670 -0
- package/mods/so-hud/hooks/rules.ts +2998 -0
- package/mods/so-hud/tsconfig.json +8 -0
- package/mods/so-hud/types/index.d.ts +36 -0
- package/package.json +4 -2
- package/rules/_index.md +1 -1
- package/rules/always-on/ask-via-tool.md +1 -1
- package/rules/always-on/loop-and-monitor.md +17 -194
- package/rules/always-on/parallel-sessions.md +3 -1
- package/rules/opt-in-stack/backend-data.md +1 -1
- package/rules/opt-in-stack/backend.md +1 -1
- package/rules/opt-in-stack/security-web.md +5 -1
- package/scripts/agent-usage-census.mjs +79 -0
- package/scripts/apply-session-learnings.mjs +34 -6
- package/scripts/archive-closed-prds.mjs +21 -5
- package/scripts/autopilot.mjs +11 -5
- package/scripts/backfill-abandoned-sessions.mjs +13 -3
- package/scripts/backfill-evidence-digest.mjs +2 -1
- package/scripts/backfill-learnings-from-vault.mjs +61 -6
- package/scripts/backfill-sessions.mjs +16 -2
- package/scripts/check-package-manager.mjs +26 -8
- package/scripts/check-sessions-integrity.mjs +4 -1
- package/scripts/ci/assert-vitest-green.mjs +3 -0
- package/scripts/ci/windows-smoke.mjs +201 -0
- package/scripts/close-precheck.mjs +55 -0
- package/scripts/dialectic-deriver.mjs +20 -12
- package/scripts/emit-event.mjs +2 -2
- package/scripts/emit-session.mjs +239 -20
- package/scripts/eval-session.mjs +17 -3
- package/scripts/export-hw-learnings.mjs +29 -2
- package/scripts/fleet-checkin-audit.mjs +8 -4
- package/scripts/fleet-checkin.mjs +39 -27
- package/scripts/gc-stale-worktrees.mjs +6 -1
- package/scripts/github-export.mjs +128 -0
- package/scripts/github-protection-audit.mjs +3 -1
- package/scripts/handover-queue-check.mjs +209 -0
- package/scripts/harness-audit.mjs +6 -4
- package/scripts/lib/agent-frontmatter.mjs +57 -10
- package/scripts/lib/agent-status.mjs +125 -7
- package/scripts/lib/agent-usage-census.mjs +394 -0
- package/scripts/lib/auto-dialectic.mjs +17 -10
- package/scripts/lib/auto-dream.mjs +22 -14
- package/scripts/lib/autopilot/loop.mjs +3 -1
- package/scripts/lib/autopilot/recent-runs.mjs +4 -2
- package/scripts/lib/autopilot/stall-sampler.mjs +2 -1
- package/scripts/lib/autopilot/telemetry.mjs +5 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/baseline-archetypes.mjs +10 -2
- package/scripts/lib/blocked-commands-policy.mjs +4 -3
- package/scripts/lib/build-live-signals.mjs +13 -9
- package/scripts/lib/ci-status-banner.mjs +329 -24
- package/scripts/lib/codex/plugin-contract.mjs +8 -0
- package/scripts/lib/cold-start-detector.mjs +5 -2
- package/scripts/lib/command-blocker.mjs +603 -43
- package/scripts/lib/common.mjs +3 -2
- package/scripts/lib/config/cross-repo.mjs +74 -7
- package/scripts/lib/config/host-paths.mjs +6 -2
- package/scripts/lib/config/section-extractor.mjs +65 -11
- package/scripts/lib/config/template-keys.mjs +153 -0
- package/scripts/lib/config/vault-integration.mjs +1 -0
- package/scripts/lib/config.mjs +2 -2
- package/scripts/lib/coordinator-snapshot.mjs +1 -1
- package/scripts/lib/discovery/triage-state.mjs +2 -1
- package/scripts/lib/dispatcher/rank.mjs +5 -7
- package/scripts/lib/ecosystem-health.mjs +2 -2
- package/scripts/lib/eval/engine.mjs +3 -2
- package/scripts/lib/eval/session-resolve.mjs +6 -2
- package/scripts/lib/eval/sink.mjs +4 -3
- package/scripts/lib/events-retention-banner.mjs +3 -2
- package/scripts/lib/events-schema.mjs +10 -0
- package/scripts/lib/events.mjs +23 -12
- package/scripts/lib/evolve/autonomy-verdict.mjs +4 -9
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +8 -18
- package/scripts/lib/fleet-protocol.mjs +4 -0
- package/scripts/lib/gates/gate-full.mjs +172 -11
- package/scripts/lib/gates/gate-helpers.mjs +236 -23
- package/scripts/lib/git-config-drift.mjs +141 -7
- package/scripts/lib/github-export.mjs +447 -0
- package/scripts/lib/gitlab-portfolio/cli.mjs +16 -5
- package/scripts/lib/handover-queue-marks.mjs +159 -0
- package/scripts/lib/hardware-pattern-detector.mjs +103 -13
- package/scripts/lib/harness-audit/categories/category1.mjs +16 -4
- package/scripts/lib/harness-audit/categories/category4.mjs +11 -8
- package/scripts/lib/harness-audit/categories/category6.mjs +25 -5
- package/scripts/lib/harness-audit/categories/category9.mjs +10 -4
- package/scripts/lib/host-identity.mjs +9 -5
- package/scripts/lib/http-request.mjs +64 -0
- package/scripts/lib/instruction-budget-guard.mjs +30 -22
- package/scripts/lib/io.mjs +6 -3
- package/scripts/lib/issue-budget-reconcile.mjs +3 -2
- package/scripts/lib/issue-budget.mjs +36 -7
- package/scripts/lib/learnings/evidence-candidates.mjs +154 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +99 -7
- package/scripts/lib/learnings/io.mjs +289 -15
- package/scripts/lib/learnings/select.mjs +53 -4
- package/scripts/lib/learnings/store-role.mjs +209 -0
- package/scripts/lib/learnings/surface.mjs +46 -7
- package/scripts/lib/ledger-path.mjs +113 -0
- package/scripts/lib/maintenance-due-banner.mjs +63 -12
- package/scripts/lib/memory-banner.mjs +5 -6
- package/scripts/lib/memory-proposals/collector.mjs +5 -2
- package/scripts/lib/memory-proposals/sink.mjs +7 -4
- package/scripts/lib/memory-proposals/store.mjs +22 -5
- package/scripts/lib/mode-selector-accuracy.mjs +2 -1
- package/scripts/lib/no-follow-open.mjs +57 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +4 -2
- package/scripts/lib/own-lock-root.mjs +95 -0
- package/scripts/lib/owner-interview.mjs +2 -1
- package/scripts/lib/owner-yaml.mjs +70 -7
- package/scripts/lib/platform.mjs +161 -9
- package/scripts/lib/plugin-update-banner.mjs +15 -11
- package/scripts/lib/pre-push-local-partial.mjs +117 -0
- package/scripts/lib/pre-push-remote-gate.mjs +151 -25
- package/scripts/lib/process-group.mjs +270 -34
- package/scripts/lib/project-hygiene.mjs +28 -0
- package/scripts/lib/quality-gate.mjs +33 -9
- package/scripts/lib/reconcile/backlog.mjs +29 -15
- package/scripts/lib/reconcile/engine.mjs +26 -2
- package/scripts/lib/reconcile/idempotency.mjs +65 -3
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +53 -12
- package/scripts/lib/reconcile/verdict-ledger.mjs +224 -0
- package/scripts/lib/reconcile-nudge-banner.mjs +40 -6
- package/scripts/lib/resource-probe/evaluate.mjs +32 -9
- package/scripts/lib/resource-probe/probe-platform.mjs +3 -1
- package/scripts/lib/rule-loader.mjs +20 -10
- package/scripts/lib/scope-baseline.mjs +123 -8
- package/scripts/lib/scope-echo.mjs +5 -2
- package/scripts/lib/scope-gate.mjs +55 -20
- package/scripts/lib/session-close-backfill.mjs +75 -7
- package/scripts/lib/session-end/close-precheck.mjs +151 -0
- package/scripts/lib/session-end/phase-skip.mjs +8 -7
- package/scripts/lib/session-end/tail-runner.mjs +7 -2
- package/scripts/lib/session-id.mjs +9 -1
- package/scripts/lib/session-identity/own-session.mjs +63 -0
- package/scripts/lib/session-lock.mjs +114 -0
- package/scripts/lib/session-record-repair.mjs +245 -35
- package/scripts/lib/session-registry.mjs +72 -9
- package/scripts/lib/session-schema/constants.mjs +9 -0
- package/scripts/lib/session-schema/filters.mjs +56 -1
- package/scripts/lib/session-schema/validator.mjs +15 -5
- package/scripts/lib/session-start-probes.mjs +149 -22
- package/scripts/lib/session-token-rollup.mjs +2 -1
- package/scripts/lib/session-work-trees.mjs +166 -0
- package/scripts/lib/sessions-canonical.mjs +61 -3
- package/scripts/lib/sessions-integrity-banner.mjs +17 -39
- package/scripts/lib/sessions-staleness-banner.mjs +8 -5
- package/scripts/lib/sh-quote.mjs +1 -0
- package/scripts/lib/skill-evidence-window.mjs +5 -1
- package/scripts/lib/skill-health/join.mjs +10 -9
- package/scripts/lib/skill-invocations-schema.mjs +13 -0
- package/scripts/lib/skill-judgments-schema.mjs +6 -2
- package/scripts/lib/snapshot-probe.mjs +414 -0
- package/scripts/lib/so-hud/collect.mjs +49 -5
- package/scripts/lib/so-hud/runtime.mjs +15 -4
- package/scripts/lib/spiral-carryover.mjs +8 -1
- package/scripts/lib/state-md/frontmatter-mutators.mjs +266 -8
- package/scripts/lib/state-md/idle-reset.mjs +14 -5
- package/scripts/lib/state-md/mission-status.mjs +5 -1
- package/scripts/lib/state-md-peer-guard.mjs +2 -0
- package/scripts/lib/state-md.mjs +31 -8
- package/scripts/lib/sunset/walker.mjs +4 -3
- package/scripts/lib/telemetry/consent.mjs +4 -2
- package/scripts/lib/telemetry/queue.mjs +3 -3
- package/scripts/lib/telemetry/sync.mjs +45 -9
- package/scripts/lib/test-runner/artifact-paths.mjs +10 -2
- package/scripts/lib/tmux-layout/layouts.mjs +20 -3
- package/scripts/lib/ux-grill/paths.mjs +5 -1
- package/scripts/lib/validate/check-agents.mjs +37 -6
- package/scripts/lib/validate/check-component-paths.mjs +25 -1
- package/scripts/lib/validate/check-guard-requires-parity.mjs +3 -3
- package/scripts/lib/validate/check-hooks-symmetry.mjs +53 -32
- package/scripts/lib/validate/check-json-files.mjs +3 -1
- package/scripts/lib/validate/check-learning-provenance.mjs +3 -2
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +10 -4
- package/scripts/lib/validate/check-metrics-ledger-root.mjs +234 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +159 -11
- package/scripts/lib/validate/check-test-posix-shell.mjs +246 -0
- package/scripts/lib/validate/check-unwired-features.mjs +4 -2
- package/scripts/lib/validate/check-vcs-repo-flag-positional.mjs +58 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +259 -73
- package/scripts/lib/validate/check-workflow-continue-on-error.mjs +63 -0
- package/scripts/lib/validate/confidential-names.mjs +3 -1
- package/scripts/lib/vault-mirror/migrate-ids.mjs +321 -0
- package/scripts/lib/vault-mirror/namespace.mjs +19 -7
- package/scripts/lib/vault-mirror/process.mjs +56 -27
- package/scripts/lib/vault-mirror/prune.mjs +5 -2
- package/scripts/lib/vault-mirror/render-learnings.mjs +3 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +36 -53
- package/scripts/lib/vault-mirror/retention.mjs +2 -2
- package/scripts/lib/vault-mirror/utils.mjs +79 -0
- package/scripts/lib/vault-staleness-banner.mjs +4 -7
- package/scripts/lib/vault-status/board-writer.mjs +2 -1
- package/scripts/lib/vault-status/narrative-mirror.mjs +66 -7
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +20 -0
- package/scripts/lib/win-process-tree.mjs +137 -0
- package/scripts/lib/workspace.mjs +1 -1
- package/scripts/lib/worktree/constants.mjs +1 -1
- package/scripts/lib/worktree/lifecycle.mjs +1 -1
- package/scripts/lib/worktree/listing.mjs +1 -1
- package/scripts/lib/zx.mjs +50 -0
- package/scripts/mcp-server.mjs +16 -7
- package/scripts/mcp-server.sh +13 -83
- package/scripts/measure-policy-cache-effectiveness.mjs +7 -5
- package/scripts/memory-propose.mjs +10 -3
- package/scripts/migrate-cold-start-seed.mjs +7 -0
- package/scripts/migrate-legacy-learnings.sh +22 -0
- package/scripts/migrate-sessions-jsonl.mjs +16 -2
- package/scripts/migrate-vault-paths.mjs +29 -13
- package/scripts/parse-config.mjs +71 -14
- package/scripts/print-learnings-index.mjs +25 -2
- package/scripts/release-session-lock.mjs +94 -7
- package/scripts/release.mjs +451 -24
- package/scripts/relocate-vault-corpus.mjs +11 -7
- package/scripts/repair-invalid-sessions.mjs +68 -3
- package/scripts/resolve-session-invocation.mjs +10 -17
- package/scripts/run-migrate-v2-cross-repo.mjs +56 -1
- package/scripts/run-quality-gate.mjs +33 -7
- package/scripts/self-update.mjs +56 -7
- package/scripts/session-commit-files.mjs +133 -0
- package/scripts/site-numbers.mjs +52 -9
- package/scripts/snapshot-probe.mjs +156 -0
- package/scripts/so-hud.mjs +29 -6
- package/scripts/state-md-idle-reset.mjs +2 -1
- package/scripts/sweep-expired-learnings.mjs +54 -6
- package/scripts/sweep-expired-rules.mjs +4 -1
- package/scripts/validate-plugin.mjs +22 -0
- package/scripts/vault-integration-watcher.mjs +54 -6
- package/scripts/vault-mirror.mjs +9 -0
- package/skills/_shared/fleet-protocol.md +142 -1
- package/skills/_shared/instruction-file-resolution.md +1 -1
- package/skills/_shared/monitor-patterns.md +44 -25
- package/skills/_shared/platform-tools.md +7 -0
- package/skills/_shared/state-ownership.md +3 -2
- package/skills/architecture/references/domain-model.md +1 -1
- package/skills/bootstrap/SKILL.md +3 -1
- package/skills/bootstrap/_shared-template.md +4 -4
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/bootstrap/private-contract.md +1 -1
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +17 -9
- package/skills/bootstrap/templates/agents/project-code-review.md +5 -1
- package/skills/bootstrap/templates/agents/project-discovery.md +5 -1
- package/skills/bootstrap/templates/agents/project-quality-gate.md +5 -1
- package/skills/claude-md-drift-check/SKILL.md +3 -3
- package/skills/claude-md-drift-check/checker.mjs +61 -101
- package/skills/close/SKILL.md +20 -7
- package/skills/discovery/probes/docs-staleness.mjs +7 -5
- package/skills/discovery/probes/frontend-slop.mjs +7 -3
- package/skills/discovery/probes/ssot-code-diff.mjs +5 -3
- package/skills/discovery/probes/supply-chain-slopcheck.mjs +10 -8
- package/skills/discovery/probes/vault-narrative-staleness.mjs +30 -10
- package/skills/discovery/probes/vault-staleness.mjs +5 -3
- package/skills/discovery/probes-session.md +3 -0
- package/skills/discovery/probes-vault.md +2 -0
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/eval/SKILL.md +3 -1
- package/skills/evolve/SKILL.md +2 -0
- package/skills/evolve/references/evolve-analyze-mode.md +3 -3
- package/skills/evolve/references/evolve-dialectic-mode.md +1 -1
- package/skills/gitlab-ops/SKILL.md +7 -6
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/go/SKILL.md +13 -1
- package/skills/npm-publish/SKILL.md +5 -2
- package/skills/plan/SKILL.md +23 -10
- package/skills/plan/mode-feature.md +13 -8
- package/skills/plan/mode-new.md +6 -14
- package/skills/plan/prd-feature-template.md +19 -0
- package/skills/plan/prd-full-template.md +12 -5
- package/skills/plan/prd-premortem-panel.md +48 -0
- package/skills/plan/prd-reviewer-prompt.md +11 -3
- package/skills/plan/soul.md +1 -1
- package/skills/portfolio/SKILL.md +5 -6
- package/skills/quality-gates/SKILL.md +2 -4
- package/skills/reconcile/SKILL.md +27 -4
- package/skills/release/SKILL.md +16 -8
- package/skills/session-end/SKILL.md +94 -11
- package/skills/session-end/phase-3-2-docs-verification.md +1 -0
- package/skills/session-end/phase-3-6-tail.md +11 -7
- package/skills/session-end/phase-3-7a-recommendations.md +9 -2
- package/skills/session-end/plan-verification.md +16 -1
- package/skills/session-end/references/phase-3-documentation-updates.md +14 -12
- package/skills/session-end/references/phase-5-issue-cleanup.md +1 -1
- package/skills/session-end/references/session-summary-template.md +4 -0
- package/skills/session-end/session-metrics-write.md +7 -4
- package/skills/session-plan/SKILL.md +5 -3
- package/skills/session-start/SKILL.md +9 -3
- package/skills/session-start/phase-4-5-resource-health.md +11 -5
- package/skills/session-start/phase-8-5-express-path.md +4 -3
- package/skills/session-start/references/phase-1-2-session-lock.md +7 -5
- package/skills/session-start/references/phase-1-5-session-continuity.md +2 -2
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +3 -2
- package/skills/tmux-layout/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +3 -2
- package/skills/wave-executor/SKILL.md +5 -4
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -3
- package/skills/wave-executor/references/wave-loop-dispatch.md +11 -5
- package/skills/wave-executor/references/wave-loop-review.md +13 -5
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +1 -1
- package/assets/og-card.png +0 -0
- package/assets/og-card.svg +0 -47
- package/assets/wave-lifecycle.svg +0 -98
|
@@ -373,9 +373,94 @@ function readHeredocBody(command, from, delim, stripTabs) {
|
|
|
373
373
|
* with it `rm -rf /tmp/x &> log` staying ALLOWED); the `;`/`|`/`&` branch runs
|
|
374
374
|
* first and is never shadowed.
|
|
375
375
|
*
|
|
376
|
+
* ## Subshells and command substitutions (#1667)
|
|
377
|
+
*
|
|
378
|
+
* Before #1667 `(`, `$(`, `)` and backticks were word characters, so
|
|
379
|
+
* `true && (rm -rf X)` resolved the verb `(rm` and every verb- or target-based
|
|
380
|
+
* check (path-delete denylist, rm allowlist, issue budget, staging fence)
|
|
381
|
+
* missed X. The ledger guard had fixed this in its own scanner (#1665); the
|
|
382
|
+
* shared lexer now carries it for every consumer:
|
|
383
|
+
* - An unquoted `(` at WORD START opens a subshell: it and its `)` become
|
|
384
|
+
* `{ text: ';', operator: 'subshell' }` separators, the body stays in place.
|
|
385
|
+
* - `$( … )`, backticks (unquoted AND inside double quotes) and process
|
|
386
|
+
* substitution `<( … )` / `>( … )` are WORDS whose body runs as a command:
|
|
387
|
+
* the outer word keeps the RAW source text (`$(pwd)/x` stays one operand,
|
|
388
|
+
* so no outer word boundary moves) and the body's tokens are HOISTED to the
|
|
389
|
+
* end of the stream behind a `{ text: ';', operator: 'substitution' }`.
|
|
390
|
+
* - Unchanged on purpose: `$((`/`((` arithmetic, a mid-word `(` (`f()`,
|
|
391
|
+
* `arr=(…)`, whose `)` stays word text), an unmatched `)`, the parens of a
|
|
392
|
+
* case HEAD (`(a)` patterns), quoted/escaped parens, `{`/`}` (brace groups
|
|
393
|
+
* were already split by COMMAND_POSITION_KEYWORDS), here-doc bodies.
|
|
394
|
+
* - A case pattern's closing `)` is followed by a `{ text: ';', operator:
|
|
395
|
+
* 'case-pattern' }` separator, so an arm's first command (`a) rm x;;`)
|
|
396
|
+
* heads its own segment; in an arm body `(` opens a subshell again.
|
|
397
|
+
* - Fail direction: an unclosed substitution is closed at end of input and
|
|
398
|
+
* STILL hoisted (verb visible). Past MAX_TOKENIZER_NESTING an unquoted
|
|
399
|
+
* opener degrades to a plain separator (verb visible); a double-quoted one
|
|
400
|
+
* stays literal text there.
|
|
401
|
+
*
|
|
376
402
|
* @param {string} command
|
|
377
403
|
* @returns {Array<{ text: string, quoted: boolean, redirect?: { fd: number|null, mode: string } }>}
|
|
378
404
|
*/
|
|
405
|
+
/** Unquoted chain-operator texts — a word after one of them is in command position. */
|
|
406
|
+
const CHAIN_OPERATOR_TEXTS = new Set([';', '&&', '||', '|', '&']);
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Reserved words that are followed by a COMMAND (#1671): a word after one of
|
|
410
|
+
* them — itself in command position — is in command position too. The opener
|
|
411
|
+
* half of COMMAND_POSITION_KEYWORDS (closers like `fi`/`done` end a command, so
|
|
412
|
+
* `fi case` is no case) plus `time`, which bash parses as a pipeline prefix.
|
|
413
|
+
* `(` is absent on purpose: since #1667 the lexer emits it as a separator.
|
|
414
|
+
*/
|
|
415
|
+
const COMMAND_POSITION_OPENERS = new Set([
|
|
416
|
+
'{', '!', 'if', 'elif', 'then', 'else', 'while', 'until', 'do', 'time', 'coproc',
|
|
417
|
+
]);
|
|
418
|
+
|
|
419
|
+
/** A function-definition head `name()` — its body is a compound command. */
|
|
420
|
+
const FUNCTION_HEAD_RE = /^[^\s()$`'"]+\(\)$/;
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Is the word at index `end` of `sink` (default: the NEXT word pushed) in
|
|
424
|
+
* command position (#1671)? Walks back over
|
|
425
|
+
* a run of unquoted opener keywords (`if ! case …`, `then { case …`) to the
|
|
426
|
+
* statement start: an operator/separator or the start of the sink. An opener
|
|
427
|
+
* that is itself an argument (`echo then case`) does not count. Also openers:
|
|
428
|
+
* `coproc`, a function head `name()` / `function name`, and `time -p`.
|
|
429
|
+
* A case pattern is followed by a `case-pattern` separator token (see
|
|
430
|
+
* {@link tokenizeCommand}), so the first word of a case ARM is in command
|
|
431
|
+
* position through the operator branch.
|
|
432
|
+
*
|
|
433
|
+
* @param {Array<{ text: string, quoted: boolean, operator?: string }>} sink
|
|
434
|
+
* @param {number} [end=sink.length]
|
|
435
|
+
* @returns {boolean}
|
|
436
|
+
*/
|
|
437
|
+
function nextWordInCommandPosition(sink, end = sink.length) {
|
|
438
|
+
for (let i = end - 1; i >= 0; i--) {
|
|
439
|
+
const tok = sink[i];
|
|
440
|
+
if (tok.quoted) return false;
|
|
441
|
+
if (tok.operator !== undefined || CHAIN_OPERATOR_TEXTS.has(tok.text)) return true;
|
|
442
|
+
const prev = sink[i - 1];
|
|
443
|
+
const prevIs = (word) => prev !== undefined && !prev.quoted && prev.text === word;
|
|
444
|
+
// `time -p cmd`: the POSIX-format flag is part of the `time` prefix.
|
|
445
|
+
if (tok.text === '-p' && prevIs('time')) continue;
|
|
446
|
+
// `function name { … }` / `function name() …`: skip the keyword+name pair.
|
|
447
|
+
if (prevIs('function')) { i--; continue; }
|
|
448
|
+
if (FUNCTION_HEAD_RE.test(tok.text)) continue;
|
|
449
|
+
if (!COMMAND_POSITION_OPENERS.has(tok.text)) return false;
|
|
450
|
+
}
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* How many `$( … )` / backtick / `( … )` levels {@link tokenizeCommand} tracks
|
|
456
|
+
* (#1667, mirroring the ledger guard's MAX_SCAN_NESTING). The lexer is a loop
|
|
457
|
+
* with an explicit frame stack, so this is a work bound, not a stack-safety
|
|
458
|
+
* bound. Past it an unquoted opener is read as a plain separator: the verb
|
|
459
|
+
* behind it stays visible (fail-closed for verb checks), only the word-boundary
|
|
460
|
+
* fidelity of that pathological level is given up.
|
|
461
|
+
*/
|
|
462
|
+
const MAX_TOKENIZER_NESTING = 256;
|
|
463
|
+
|
|
379
464
|
export function tokenizeCommand(command) {
|
|
380
465
|
const tokens = [];
|
|
381
466
|
if (typeof command !== 'string' || command.length === 0) return tokens;
|
|
@@ -384,19 +469,103 @@ export function tokenizeCommand(command) {
|
|
|
384
469
|
let text = '';
|
|
385
470
|
let started = false; // a token is in progress
|
|
386
471
|
let sawQuote = false; // any char of the current token came from inside quotes
|
|
472
|
+
// Leading UNQUOTED `{` characters of the current token (#1707 point 2). For a
|
|
473
|
+
// token that is otherwise quoted (`{"unlink"`) the `quoted` flag cannot say
|
|
474
|
+
// whether its first `{` was bare, so the count rides on the token.
|
|
475
|
+
let braceLead = 0;
|
|
476
|
+
// Index of the last BARE `(` appended to a word (`f(`, `arr=(`). An escaped
|
|
477
|
+
// `\(` or a quoted one never sets it, so `$(echo \()` still closes there.
|
|
478
|
+
let bareParenAt = -1;
|
|
387
479
|
let state = 'normal'; // 'normal' | 'single' | 'double' | 'ansi'
|
|
388
480
|
let arithDepth = 0; // open `$((` / `((` levels — inside them `<<` is a shift
|
|
481
|
+
// One entry per open `case … esac` level, innermost last. Each names what the
|
|
482
|
+
// NEXT word at that level is: 'subject' (`case WORD`), 'in' (the `in`),
|
|
483
|
+
// 'pattern' (a clause pattern, or `esac`) or 'body' (commands of an arm).
|
|
484
|
+
// In the three head states `(`/`)` are pattern syntax; an unquoted `)` in
|
|
485
|
+
// 'pattern' ENDS the pattern and emits a `case-pattern` separator, so the
|
|
486
|
+
// arm's first word (`a) rm -f x`, `a)rm -f x`) heads its own segment.
|
|
487
|
+
let caseStates = [];
|
|
488
|
+
const caseTop = () => (caseStates.length > 0 ? caseStates[caseStates.length - 1] : null);
|
|
489
|
+
const setCaseTop = (s) => { caseStates[caseStates.length - 1] = s; };
|
|
389
490
|
const pendingHeredocs = [];
|
|
390
491
|
|
|
492
|
+
// #1667 nesting: `sink` receives the tokens of the CURRENT command context.
|
|
493
|
+
// A `$( … )` / backtick / process-substitution body gets its own sink and is
|
|
494
|
+
// HOISTED (appended after the outer stream, behind a separator) on close; a
|
|
495
|
+
// `( … )` subshell stays in place between two separators. `stack` holds one
|
|
496
|
+
// frame per open level — see the tokenizeCommand docblock.
|
|
497
|
+
let sink = tokens;
|
|
498
|
+
const hoisted = [];
|
|
499
|
+
const stack = [];
|
|
500
|
+
|
|
391
501
|
const flush = () => {
|
|
392
502
|
if (started) {
|
|
393
|
-
|
|
503
|
+
// #1671: BOTH words count only where bash reads them as reserved words.
|
|
504
|
+
// An argument `esac` (`echo esac`) used to close a case early, and a
|
|
505
|
+
// `case` behind `then`/`do`/`else`/`{`/`!` was never opened — either way
|
|
506
|
+
// the next pattern `)` closed the enclosing `$( … )` and moved the rest
|
|
507
|
+
// of the body (an `rm` operand) into the outer statement.
|
|
508
|
+
const top = caseTop();
|
|
509
|
+
if (top === 'subject') setCaseTop('in');
|
|
510
|
+
else if (top === 'in') { if (!sawQuote && text === 'in') setCaseTop('pattern'); }
|
|
511
|
+
else if (top === 'pattern') {
|
|
512
|
+
// `case w in esac` / `;; esac` — `esac` where a pattern may start.
|
|
513
|
+
if (!sawQuote && text === 'esac') caseStates.pop();
|
|
514
|
+
} else if (!sawQuote && text === 'case' && nextWordInCommandPosition(sink)) {
|
|
515
|
+
caseStates.push('subject');
|
|
516
|
+
} else if (top === 'body' && !sawQuote && text === 'esac' && nextWordInCommandPosition(sink)) {
|
|
517
|
+
// `a) esac` (last clause without `;;`) is covered too: the pattern's
|
|
518
|
+
// `case-pattern` separator puts `esac` in command position.
|
|
519
|
+
caseStates.pop();
|
|
520
|
+
}
|
|
521
|
+
const tok = { text, quoted: sawQuote };
|
|
522
|
+
// Only a QUOTED token carries the count: for an unquoted one every `{` of
|
|
523
|
+
// its text is bare, so the text already says it (and the token shape of
|
|
524
|
+
// every unquoted word stays as it was).
|
|
525
|
+
if (sawQuote && braceLead > 0) tok.braceLead = braceLead;
|
|
526
|
+
sink.push(tok);
|
|
394
527
|
text = '';
|
|
395
528
|
started = false;
|
|
396
529
|
sawQuote = false;
|
|
530
|
+
braceLead = 0;
|
|
397
531
|
}
|
|
398
532
|
};
|
|
399
533
|
|
|
534
|
+
// Open a substitution level whose BODY is lexed into a fresh sink. The outer
|
|
535
|
+
// word in progress (`x=` in `x=$(…)`, or the double-quoted run around
|
|
536
|
+
// `"$(…)"`) is parked in the frame and resumed on close.
|
|
537
|
+
const openSubstitution = (kind, start) => {
|
|
538
|
+
stack.push({ kind, start, text, started, sawQuote, braceLead, state, sink, arithDepth, caseStates });
|
|
539
|
+
text = ''; started = false; sawQuote = false; braceLead = 0; state = 'normal'; arithDepth = 0; caseStates = [];
|
|
540
|
+
sink = [];
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
// Close the innermost substitution level. `end` is the index just past its
|
|
544
|
+
// closing character (or the input length when it never closed). The body is
|
|
545
|
+
// hoisted; the outer word gets the RAW source text of the whole substitution
|
|
546
|
+
// appended, so the outer token stream is byte-identical to the pre-#1667
|
|
547
|
+
// lexer's word boundaries (`rm -rf $(pwd)/x` keeps ONE operand).
|
|
548
|
+
const closeSubstitution = (end) => {
|
|
549
|
+
if (state !== 'normal') sawQuote = true;
|
|
550
|
+
flush();
|
|
551
|
+
const frame = stack.pop();
|
|
552
|
+
// Loops, never `push(...arr)`: a spread passes every element as a call
|
|
553
|
+
// argument, and a body of ~125k tokens throws RangeError — which the
|
|
554
|
+
// destructive guard's top-level catch turns into ALLOW (fail-open).
|
|
555
|
+
hoisted.push({ text: ';', quoted: false, operator: 'substitution' });
|
|
556
|
+
for (const t of sink) hoisted.push(t);
|
|
557
|
+
({ text, started, sawQuote, braceLead, state, sink, arithDepth, caseStates } = frame);
|
|
558
|
+
const raw = command.slice(frame.start, end);
|
|
559
|
+
text += raw; started = true;
|
|
560
|
+
if (/["']/.test(raw)) sawQuote = true;
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
const topKind = () => (stack.length > 0 ? stack[stack.length - 1].kind : null);
|
|
564
|
+
const pushSubshellSeparator = () => {
|
|
565
|
+
flush();
|
|
566
|
+
sink.push({ text: ';', quoted: false, operator: 'subshell' });
|
|
567
|
+
};
|
|
568
|
+
|
|
400
569
|
// A leading all-digit word is the fd of a redirect (`2> log`), not an argument.
|
|
401
570
|
// Returns the digits to prefix onto the operator token; flushes otherwise.
|
|
402
571
|
const takeFdPrefix = () => {
|
|
@@ -429,6 +598,20 @@ export function tokenizeCommand(command) {
|
|
|
429
598
|
text += next; started = true; sawQuote = true; i++;
|
|
430
599
|
continue;
|
|
431
600
|
}
|
|
601
|
+
// Double quotes do NOT stop command substitution (#1667): `"$(rm x)"` runs
|
|
602
|
+
// `rm`. The quoted run is parked and the body is lexed as a command.
|
|
603
|
+
// `$((` stays literal arithmetic text, exactly as before.
|
|
604
|
+
if (state === 'double' && stack.length < MAX_TOKENIZER_NESTING) {
|
|
605
|
+
if (ch === '`') {
|
|
606
|
+
if (topKind() === 'backtick') { closeSubstitution(i + 1); continue; }
|
|
607
|
+
openSubstitution('backtick', i);
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
if (ch === '$' && command[i + 1] === '(' && command[i + 2] !== '(') {
|
|
611
|
+
openSubstitution('subst', i); i++;
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
432
615
|
text += ch; started = true; sawQuote = true;
|
|
433
616
|
continue;
|
|
434
617
|
}
|
|
@@ -460,7 +643,7 @@ export function tokenizeCommand(command) {
|
|
|
460
643
|
// real newline (#999).
|
|
461
644
|
if (!terminated) { lastTerm = false; pendingHeredocs.length = 0; break; }
|
|
462
645
|
lastTerm = true;
|
|
463
|
-
if (body.length > 0)
|
|
646
|
+
if (body.length > 0) sink.push({ text: body, quoted: true });
|
|
464
647
|
j = end;
|
|
465
648
|
}
|
|
466
649
|
// `readHeredocBody` returns `end` pointing PAST the newline that closes the
|
|
@@ -514,6 +697,86 @@ export function tokenizeCommand(command) {
|
|
|
514
697
|
continue;
|
|
515
698
|
}
|
|
516
699
|
|
|
700
|
+
// Nesting (#1667) — subshells, command substitutions, backticks and process
|
|
701
|
+
// substitutions. Arithmetic (`$((`/`((`, taken above) keeps every paren as
|
|
702
|
+
// text; inside `case … esac` a paren is pattern syntax (`(a)` / `a)`) and
|
|
703
|
+
// also stays text, the pre-#1667 behaviour.
|
|
704
|
+
if (arithDepth === 0) {
|
|
705
|
+
const atCap = stack.length >= MAX_TOKENIZER_NESTING;
|
|
706
|
+
if (ch === '`') {
|
|
707
|
+
if (topKind() === 'backtick') { closeSubstitution(i + 1); continue; }
|
|
708
|
+
if (atCap) { pushSubshellSeparator(); continue; }
|
|
709
|
+
openSubstitution('backtick', i);
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
if (ch === '$' && command[i + 1] === '(') {
|
|
713
|
+
if (atCap) { pushSubshellSeparator(); i++; continue; }
|
|
714
|
+
openSubstitution('subst', i); i++;
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
// Inside a case HEAD (`subject`/`in`/`pattern`) a word-start `(` is the
|
|
718
|
+
// optional leading paren of a pattern (`(a)`); in an arm BODY it is a
|
|
719
|
+
// subshell like anywhere else (`a) (rm x);;`).
|
|
720
|
+
const top = caseTop();
|
|
721
|
+
if (ch === '(' && !started && (top === null || top === 'body')) {
|
|
722
|
+
if (atCap) { pushSubshellSeparator(); continue; }
|
|
723
|
+
// `<(cmd)` / `>(cmd)`: a process substitution is a WORD (a filename),
|
|
724
|
+
// so it is hoisted like `$(…)` and the redirect keeps its operand.
|
|
725
|
+
const prev = sink[sink.length - 1];
|
|
726
|
+
if (i > 0 && (command[i - 1] === '<' || command[i - 1] === '>') && prev && prev.redirect) {
|
|
727
|
+
openSubstitution('subst', i);
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
// `caseLen`: a subshell opened in an arm body closes only at that
|
|
731
|
+
// same case level, never through a pattern `)` of a nested case.
|
|
732
|
+
stack.push({ kind: 'paren', caseLen: caseStates.length });
|
|
733
|
+
pushSubshellSeparator();
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
if (ch === ')') {
|
|
737
|
+
// `esac)` must close its case before the paren is judged.
|
|
738
|
+
if (started && !sawQuote && text === 'esac') flush();
|
|
739
|
+
// End of a case PATTERN (`a)`, `(a)`, `"a")`, `a)rm …`): keep the `)`
|
|
740
|
+
// on the pattern word and start the arm as a new statement, so its
|
|
741
|
+
// first word is a segment head (#1671 follow-up F1) and a `case`
|
|
742
|
+
// there opens (F2).
|
|
743
|
+
if (caseTop() === 'pattern') {
|
|
744
|
+
text += ch; started = true;
|
|
745
|
+
flush();
|
|
746
|
+
sink.push({ text: ';', quoted: false, operator: 'case-pattern' });
|
|
747
|
+
setCaseTop('body');
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
// `f()` / `arr=()` — the `)` right after a MID-WORD `(` is that word's
|
|
751
|
+
// own text, never the close of an enclosing `$( … )` / subshell.
|
|
752
|
+
// Keyed on WHERE that `(` came from, not on the raw previous char: an
|
|
753
|
+
// escaped `\(` would otherwise keep a `"$(echo \()"` open and swallow
|
|
754
|
+
// every following command (refute review of 8f323ad8, HIGH).
|
|
755
|
+
if (started && bareParenAt === i - 1 && text.endsWith('(')) {
|
|
756
|
+
text += ch;
|
|
757
|
+
// #1679: an adjacent compound body must start a fresh token too:
|
|
758
|
+
// `f(){unlink X;}` / `f()(unlink X)`. Only a bare, unquoted head in
|
|
759
|
+
// command position qualifies; argument text must stay an argument.
|
|
760
|
+
const bodyOpener = command[i + 1];
|
|
761
|
+
if (!sawQuote && FUNCTION_HEAD_RE.test(text) && nextWordInCommandPosition(sink) &&
|
|
762
|
+
(bodyOpener === '{' || bodyOpener === '(')) {
|
|
763
|
+
flush();
|
|
764
|
+
if (bodyOpener === '{') {
|
|
765
|
+
sink.push({ text: '{', quoted: false });
|
|
766
|
+
i++;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
const frame = stack[stack.length - 1];
|
|
772
|
+
if (topKind() === 'paren' && caseStates.length === frame.caseLen &&
|
|
773
|
+
(caseTop() === null || caseTop() === 'body')) {
|
|
774
|
+
stack.pop(); pushSubshellSeparator(); continue;
|
|
775
|
+
}
|
|
776
|
+
if (caseStates.length === 0 && topKind() === 'subst') { closeSubstitution(i + 1); continue; }
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
517
780
|
// A REAL (non-continued) newline is a command separator, exactly like `;`
|
|
518
781
|
// in the POSIX grammar (#981). MUST stay below the pending-here-doc branch
|
|
519
782
|
// above: a newline that opens or ends a here-doc body is consumed there and
|
|
@@ -528,7 +791,7 @@ export function tokenizeCommand(command) {
|
|
|
528
791
|
// consumers that care, mirroring how `redirect` marks redirect tokens.
|
|
529
792
|
if (ch === '\n') {
|
|
530
793
|
flush();
|
|
531
|
-
|
|
794
|
+
sink.push({ text: ';', quoted: false, operator: 'newline' });
|
|
532
795
|
continue;
|
|
533
796
|
}
|
|
534
797
|
|
|
@@ -544,10 +807,10 @@ export function tokenizeCommand(command) {
|
|
|
544
807
|
if (ch === '&' && command[i + 1] === '>') {
|
|
545
808
|
flush();
|
|
546
809
|
if (command[i + 2] === '>') {
|
|
547
|
-
|
|
810
|
+
sink.push({ text: '&>>', quoted: false, redirect: { fd: null, mode: 'append' } });
|
|
548
811
|
i += 2;
|
|
549
812
|
} else {
|
|
550
|
-
|
|
813
|
+
sink.push({ text: '&>', quoted: false, redirect: { fd: null, mode: 'truncate' } });
|
|
551
814
|
i += 1;
|
|
552
815
|
}
|
|
553
816
|
continue;
|
|
@@ -555,7 +818,7 @@ export function tokenizeCommand(command) {
|
|
|
555
818
|
if (ch === '<') {
|
|
556
819
|
if (command[i + 1] === '<' && command[i + 2] === '<') {
|
|
557
820
|
flush();
|
|
558
|
-
|
|
821
|
+
sink.push({ text: '<<<', quoted: false, redirect: { fd: null, mode: 'herestring' } });
|
|
559
822
|
i += 2;
|
|
560
823
|
continue;
|
|
561
824
|
}
|
|
@@ -576,7 +839,7 @@ export function tokenizeCommand(command) {
|
|
|
576
839
|
const fdDigits = takeFdPrefix();
|
|
577
840
|
const fdNum = fdDigits === '' ? null : Number.parseInt(fdDigits, 10);
|
|
578
841
|
const opText = `${fdDigits}<<${stripTabs ? '-' : ''}`;
|
|
579
|
-
|
|
842
|
+
sink.push({ text: opText, quoted: false, redirect: { fd: fdNum, mode: 'heredoc' } });
|
|
580
843
|
if (value && command[end] !== ')') {
|
|
581
844
|
pendingHeredocs.push({ delim: value, stripTabs });
|
|
582
845
|
i = end - 1;
|
|
@@ -587,7 +850,7 @@ export function tokenizeCommand(command) {
|
|
|
587
850
|
}
|
|
588
851
|
const fdDigits = takeFdPrefix();
|
|
589
852
|
const fdNum = fdDigits === '' ? null : Number.parseInt(fdDigits, 10);
|
|
590
|
-
|
|
853
|
+
sink.push({ text: `${fdDigits}<`, quoted: false, redirect: { fd: fdNum, mode: 'read' } });
|
|
591
854
|
continue;
|
|
592
855
|
}
|
|
593
856
|
if (ch === '>') {
|
|
@@ -605,20 +868,20 @@ export function tokenizeCommand(command) {
|
|
|
605
868
|
}
|
|
606
869
|
const fdText = fd === null ? '' : String(fd);
|
|
607
870
|
if (command[i + 1] === '>') {
|
|
608
|
-
|
|
871
|
+
sink.push({ text: `${fdText}>>`, quoted: false, redirect: { fd, mode: 'append' } });
|
|
609
872
|
i += 1;
|
|
610
873
|
} else if (command[i + 1] === '&' && /[0-9]/.test(command[i + 2] ?? '')) {
|
|
611
874
|
// N>&M / >&M — fd duplication; the target fd is inline, no operand word.
|
|
612
875
|
let j = i + 2;
|
|
613
876
|
let dupTarget = '';
|
|
614
877
|
while (j < command.length && /[0-9]/.test(command[j])) { dupTarget += command[j]; j++; }
|
|
615
|
-
|
|
878
|
+
sink.push({ text: `${fdText}>&${dupTarget}`, quoted: false, redirect: { fd, mode: 'dup' } });
|
|
616
879
|
i = j - 1;
|
|
617
880
|
} else if (command[i + 1] === '|') {
|
|
618
|
-
|
|
881
|
+
sink.push({ text: `${fdText}>|`, quoted: false, redirect: { fd, mode: 'truncate' } });
|
|
619
882
|
i += 1;
|
|
620
883
|
} else {
|
|
621
|
-
|
|
884
|
+
sink.push({ text: `${fdText}>`, quoted: false, redirect: { fd, mode: 'truncate' } });
|
|
622
885
|
}
|
|
623
886
|
continue;
|
|
624
887
|
}
|
|
@@ -630,12 +893,20 @@ export function tokenizeCommand(command) {
|
|
|
630
893
|
// #983 redirect token class), so this branch only ever sees a bare chain `&`.
|
|
631
894
|
if (ch === ';' || ch === '|' || ch === '&') {
|
|
632
895
|
flush();
|
|
896
|
+
// `;;` / `;&` / `;;&` end an arm: the next word at this level is a pattern.
|
|
897
|
+
if (ch === ';' && (command[i + 1] === ';' || command[i + 1] === '&') && caseTop() === 'body') {
|
|
898
|
+
setCaseTop('pattern');
|
|
899
|
+
}
|
|
633
900
|
let op = ch;
|
|
634
901
|
if ((ch === '&' || ch === '|') && command[i + 1] === ch) { op = ch + ch; i++; }
|
|
635
|
-
|
|
902
|
+
sink.push({ text: op, quoted: false });
|
|
636
903
|
continue;
|
|
637
904
|
}
|
|
638
905
|
|
|
906
|
+
if (ch === '(') bareParenAt = i;
|
|
907
|
+
// A bare `{` extends the run of leading braces only while nothing else — not
|
|
908
|
+
// even an empty quote pair (`""{x` is a literal word) — came before it.
|
|
909
|
+
if (ch === '{' && text.length === braceLead && (braceLead > 0 || !started)) braceLead++;
|
|
639
910
|
text += ch; started = true;
|
|
640
911
|
}
|
|
641
912
|
|
|
@@ -645,6 +916,18 @@ export function tokenizeCommand(command) {
|
|
|
645
916
|
if (state !== 'normal') sawQuote = true;
|
|
646
917
|
flush();
|
|
647
918
|
|
|
919
|
+
// Unclosed nesting levels (#1667): an open `( …` just ends; an open
|
|
920
|
+
// substitution is closed at end of input, so its body is STILL hoisted — the
|
|
921
|
+
// conservative direction (the verb inside stays visible) — and the outer word
|
|
922
|
+
// keeps the raw remainder.
|
|
923
|
+
while (stack.length > 0) {
|
|
924
|
+
if (topKind() === 'paren') { stack.pop(); continue; }
|
|
925
|
+
closeSubstitution(command.length);
|
|
926
|
+
if (state !== 'normal') sawQuote = true;
|
|
927
|
+
flush();
|
|
928
|
+
}
|
|
929
|
+
for (const t of hoisted) tokens.push(t); // no spread — see closeSubstitution
|
|
930
|
+
|
|
648
931
|
return tokens;
|
|
649
932
|
}
|
|
650
933
|
|
|
@@ -656,7 +939,10 @@ export function tokenizeCommand(command) {
|
|
|
656
939
|
*
|
|
657
940
|
* Two classes, both dropped, for the same reason:
|
|
658
941
|
* - OPENERS (`{`, `(`, `!`, `if`, `elif`, `then`, `else`, `while`, `until`,
|
|
659
|
-
* `do`) are followed by a COMMAND.
|
|
942
|
+
* `do`, `coproc`) are followed by a COMMAND. `coproc` runs it as a
|
|
943
|
+
* coprocess in bash and zsh (#1707 point 3). NAMED CEILING (BV-004): bash's
|
|
944
|
+
* named form `coproc NAME { …; }` still reads NAME as the verb; revisit if
|
|
945
|
+
* that spelling shows up in a transcript census. Dropping one moves verb resolution
|
|
660
946
|
* towards the real command — the identical safety argument WRAPPER_UNWRAP
|
|
661
947
|
* makes for a transparent wrapper: a dropped token is a reserved word, never
|
|
662
948
|
* an interpreter, so this cannot turn a match into a miss.
|
|
@@ -672,19 +958,120 @@ export function tokenizeCommand(command) {
|
|
|
672
958
|
* one table: a second entry here would fork the wrapper-vs-keyword decision
|
|
673
959
|
* the way `VERB_PREFIXES` once forked WRAPPER_UNWRAP (#991).
|
|
674
960
|
*
|
|
675
|
-
*
|
|
676
|
-
*
|
|
677
|
-
*
|
|
678
|
-
*
|
|
679
|
-
* token TEXT would mutate a token five consumers read positionally, which is a
|
|
680
|
-
* larger contract change than this defect justifies. Revisit if a glued-paren
|
|
681
|
-
* form shows up in a real overflow triage or guard-bypass measurement.
|
|
961
|
+
* Only a STANDALONE token is dropped. bash requires whitespace around `{` /
|
|
962
|
+
* `}`, so brace groups are always covered; a subshell written WITHOUT the space
|
|
963
|
+
* (`(rm -rf /)`) no longer reaches this table at all — since #1667 the lexer
|
|
964
|
+
* emits its parens as separators (see {@link tokenizeCommand}).
|
|
682
965
|
*/
|
|
683
966
|
const COMMAND_POSITION_KEYWORDS = new Set([
|
|
684
|
-
'{', '(', '!', 'if', 'elif', 'then', 'else', 'while', 'until', 'do',
|
|
967
|
+
'{', '(', '!', 'if', 'elif', 'then', 'else', 'while', 'until', 'do', 'coproc',
|
|
685
968
|
'}', ')', 'fi', 'done', 'esac',
|
|
686
969
|
]);
|
|
687
970
|
|
|
971
|
+
/**
|
|
972
|
+
* Is `segment` exactly a function-definition head: `name()`, `function name`
|
|
973
|
+
* or `function name()`, all unquoted?
|
|
974
|
+
*
|
|
975
|
+
* @param {Array<{ text: string, quoted: boolean }>} segment
|
|
976
|
+
* @returns {boolean}
|
|
977
|
+
*/
|
|
978
|
+
function isFunctionHead(segment) {
|
|
979
|
+
// Length first: splitSegments asks this per token, so a scan of a long
|
|
980
|
+
// segment here would make the split quadratic.
|
|
981
|
+
if (segment.length === 0 || segment.length > 2 || segment.some((t) => t.quoted)) return false;
|
|
982
|
+
// A lone `function` is zsh's anonymous function (`function { …; }`), which
|
|
983
|
+
// zsh runs on the spot — measured: zsh 5.9 prints `hi` for
|
|
984
|
+
// `function { echo hi; }` (#1699 item 13).
|
|
985
|
+
if (segment.length === 1) return segment[0].text === 'function' || FUNCTION_HEAD_RE.test(segment[0].text);
|
|
986
|
+
return segment.length === 2 && segment[0].text === 'function';
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* How many brace-group openers are glued to the front of `tok`'s command word
|
|
991
|
+
* (`{unlink` → 1, `{{unlink` → 2, `{"unlink"` → 1)? 0 when none.
|
|
992
|
+
* zsh reads a leading `{` in command position as the reserved word even
|
|
993
|
+
* without a following blank — measured with zsh 5.9: `{echo hi;}`,
|
|
994
|
+
* `f() {echo hi;}; f` and `function f() {echo hi;}; f` all print `hi`, and
|
|
995
|
+
* `{echo,hi}` fails as `command not found: echo,hi` (#1699 item 13). bash
|
|
996
|
+
* rejects or brace-expands these forms; splitting the `{` off never hides a
|
|
997
|
+
* verb there either, since the remainder was no verb before. A token that
|
|
998
|
+
* also ENDS in `}` (`{}`, `{fd}` of a named-fd redirection `{fd}>file`) is
|
|
999
|
+
* left alone: it closes itself, so no command word sits behind its `{`.
|
|
1000
|
+
*
|
|
1001
|
+
* #1707 point 2: the decision rests on the RAW leading characters, not on the
|
|
1002
|
+
* token's `quoted` flag — zsh runs `{"echo" L;}`, `{'echo' L;}` and
|
|
1003
|
+
* `{{echo L;}}`, where the `{` is bare although the rest of the word is quoted,
|
|
1004
|
+
* or is itself followed by another opener. The lexer records the bare run as
|
|
1005
|
+
* `braceLead` on a quoted token (see {@link tokenizeCommand}); a token that is
|
|
1006
|
+
* quoted from its first character (`"{unlink"`) carries none and stays a word.
|
|
1007
|
+
*
|
|
1008
|
+
* @param {{ text: string, quoted: boolean, braceLead?: number, redirect?: object, operator?: string }} tok
|
|
1009
|
+
* @returns {number}
|
|
1010
|
+
*/
|
|
1011
|
+
function gluedBraceOpenerCount(tok) {
|
|
1012
|
+
if (tok.redirect || tok.operator !== undefined) return 0;
|
|
1013
|
+
let n = 0;
|
|
1014
|
+
if (tok.quoted) n = tok.braceLead ?? 0;
|
|
1015
|
+
else while (n < tok.text.length && tok.text[n] === '{') n++;
|
|
1016
|
+
if (n === 0 || n >= tok.text.length || tok.text.endsWith('}')) return 0;
|
|
1017
|
+
return n;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* The extra reading of a word that ends in a `}` glued to it inside an open
|
|
1022
|
+
* brace group (#1707 point 1): zsh 5.9 prints `-r X` for `{echo -r X}` — the
|
|
1023
|
+
* glued `}` closes the group and is not part of the word — while bash keeps it
|
|
1024
|
+
* as text. An option table cannot enumerate unknown forms, so both readings
|
|
1025
|
+
* are judged and never one picked: the word itself stays, and the word with
|
|
1026
|
+
* ONE trailing `}` stripped is the second reading.
|
|
1027
|
+
*
|
|
1028
|
+
* zsh closes exactly ONE group per glued `}` word, however many braces trail
|
|
1029
|
+
* it (zsh 5.9, measured 2026-10-22): `{echo X}}` prints `X}`, `{{echo X}}` is a
|
|
1030
|
+
* parse error, `{ echo X }}}` prints `X }}`, `{ { echo a}}; echo X}` prints
|
|
1031
|
+
* `a}` then `X` (the outer group stays open until `X}`), and
|
|
1032
|
+
* `{ { { echo a}}}; echo X}; echo Y}` prints `a}}`, `X`, `Y`. Counting several
|
|
1033
|
+
* closes for one word left a later glued `}` with no reading:
|
|
1034
|
+
* `{ { echo a}}; echo x >CLAUDE.md}` truncates `CLAUDE.md` in zsh. One reading
|
|
1035
|
+
* per word also keeps the work linear on a `{`×N `X` `}`×N flood.
|
|
1036
|
+
*
|
|
1037
|
+
* A glued `}` word never lowers `openGroups` (security re-review of f99e1352):
|
|
1038
|
+
* the reader cannot tell a real closer from a word zsh keeps as text —
|
|
1039
|
+
* `${HOME}`, `{a,b}`, `"a}"`, `a\}` — and counting one of those as a close left
|
|
1040
|
+
* the REAL closer behind it with no reading: `{ echo ${HOME}; echo x >CLAUDE.md}`
|
|
1041
|
+
* truncates `CLAUDE.md` in zsh 5.9. Only a `}` keyword in command position
|
|
1042
|
+
* closes a group (see {@link splitSegments}).
|
|
1043
|
+
*
|
|
1044
|
+
* NAMED CEILING (BV-004): this over-counts open groups, deliberately fail-closed
|
|
1045
|
+
* — an extra open group only ADDS a reading, never drops one. What it
|
|
1046
|
+
* over-blocks: once a glued group has really closed, a later `x}` word outside
|
|
1047
|
+
* it still yields `x` too (`{ echo a}; rm x}` is judged as `x}` and `x`).
|
|
1048
|
+
* Revisit if a false DENY on a real transcript command traces to this reading.
|
|
1049
|
+
*
|
|
1050
|
+
* @param {string} text
|
|
1051
|
+
* @param {number} openGroups
|
|
1052
|
+
* @returns {string[]} the one stripped reading, or [] when the word has no
|
|
1053
|
+
* glued `}`, no group is open, or the word is only `}`.
|
|
1054
|
+
*/
|
|
1055
|
+
function gluedCloseReadings(text, openGroups) {
|
|
1056
|
+
if (openGroups === 0 || text.length < 2 || !text.endsWith('}')) return [];
|
|
1057
|
+
return [text.slice(0, -1)];
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* Is `segment` exactly a prefix that puts the next word — a brace group or any
|
|
1062
|
+
* other compound command — in command position? A function head (see
|
|
1063
|
+
* {@link isFunctionHead}) or `time` / `time -p`, which bash and zsh both parse
|
|
1064
|
+
* as a pipeline prefix (#1707 point 3: zsh runs `time {unlink P;}`).
|
|
1065
|
+
*
|
|
1066
|
+
* @param {Array<{ text: string, quoted: boolean }>} segment
|
|
1067
|
+
* @returns {boolean}
|
|
1068
|
+
*/
|
|
1069
|
+
function isCompoundPrefix(segment) {
|
|
1070
|
+
if (isFunctionHead(segment)) return true;
|
|
1071
|
+
if (segment.length === 0 || segment.length > 2 || segment.some((t) => t.quoted)) return false;
|
|
1072
|
+
return segment[0].text === 'time' && (segment.length === 1 || segment[1].text === '-p');
|
|
1073
|
+
}
|
|
1074
|
+
|
|
688
1075
|
/**
|
|
689
1076
|
* Split a tokenized command into chained segments on shell control operators
|
|
690
1077
|
* (`;`, `&&`, `||`, `|`, `&`) and on newline separators (#981). Only UNQUOTED
|
|
@@ -699,6 +1086,16 @@ const COMMAND_POSITION_KEYWORDS = new Set([
|
|
|
699
1086
|
* `for t in a b c; do glab issue create …; done` and `{ glab issue create …; }`
|
|
700
1087
|
* were allowed with NO accounting at all, while the plain form denied at 1/1.
|
|
701
1088
|
*
|
|
1089
|
+
* zsh keyword bodies (#1707 point 3), each judged in command position:
|
|
1090
|
+
* `repeat N cmd` / `repeat N { …; }` (the count word is dropped with the
|
|
1091
|
+
* keyword), `{ …; } always { …; }` (an `always` right after a group's closing
|
|
1092
|
+
* `}` starts a new statement), `time { …; }` (see {@link isCompoundPrefix}) and
|
|
1093
|
+
* zsh's short function body `f() cmd`. Inside an open brace group a word with a
|
|
1094
|
+
* glued `}` carries `closeReadings` (see {@link gluedCloseReadings}) for the
|
|
1095
|
+
* delete and redirect target surfaces. Only a command-position `}` keyword
|
|
1096
|
+
* lowers the open-group count; a glued `}` word never does, so the count may
|
|
1097
|
+
* over-state open groups — a fail-closed ceiling named in gluedCloseReadings.
|
|
1098
|
+
*
|
|
702
1099
|
* The newline separator is checked by its `operator` field as well as its text,
|
|
703
1100
|
* so the split survives a future change to that token's spelling. Because a
|
|
704
1101
|
* separator token is CONSUMED here, it can never reach a per-segment operand
|
|
@@ -710,30 +1107,181 @@ const COMMAND_POSITION_KEYWORDS = new Set([
|
|
|
710
1107
|
* parsing (#982/#983). The hook previously kept a drift-prone local mirror
|
|
711
1108
|
* of this splitter (regex-based operator set vs. this Set) — W4 B1.
|
|
712
1109
|
*
|
|
1110
|
+
* An `always` after a GLUED or one-word close (`x}`, `{true}`) has two readings
|
|
1111
|
+
* and both are judged (review of !233): zsh may read the word as closing the
|
|
1112
|
+
* group, so `always` starts a new statement; bash knows no `always` and passes
|
|
1113
|
+
* it and every word behind it to the command — `bash -c '{ rm -f x} always
|
|
1114
|
+
* CLAUDE.md; }'` deletes CLAUDE.md. The bash reading keeps every statement; from
|
|
1115
|
+
* the first soft cut on, each zsh statement is added unless the bash reading has
|
|
1116
|
+
* the same words, in which case its glued-close readings are merged into that
|
|
1117
|
+
* statement instead — `{true} always {echo a; rm CLAUDE.md}` keeps `CLAUDE.md`
|
|
1118
|
+
* from zsh's open `always` group, and no statement is counted twice (the
|
|
1119
|
+
* issue-budget guard counts per segment). A command-position `}` is no such
|
|
1120
|
+
* case: `} always` is a bash syntax error, the cut is hard.
|
|
1121
|
+
*
|
|
713
1122
|
* @param {Array<{ text: string, quoted: boolean }>} tokens
|
|
714
|
-
* @returns {Array<Array<{ text: string, quoted: boolean }>>}
|
|
1123
|
+
* @returns {Array<Array<{ text: string, quoted: boolean, closeReadings?: string[] }>>}
|
|
715
1124
|
*/
|
|
716
1125
|
function splitSegments(tokens) {
|
|
1126
|
+
const zsh = splitSegmentsReading(tokens, true);
|
|
1127
|
+
if (!zsh.softCut) return zsh.segments;
|
|
1128
|
+
const segments = splitSegmentsReading(tokens, false).segments;
|
|
1129
|
+
const byWords = new Map(segments.map((segment, i) => [segmentKey(segment), i]));
|
|
1130
|
+
for (const segment of zsh.segments) {
|
|
1131
|
+
if (!segment.afterSoftCut) continue;
|
|
1132
|
+
const key = segmentKey(segment);
|
|
1133
|
+
const at = byWords.get(key);
|
|
1134
|
+
if (at === undefined) {
|
|
1135
|
+
byWords.set(key, segments.length);
|
|
1136
|
+
segments.push(segment);
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
segments[at] = mergeCloseReadings(segments[at], segment);
|
|
1140
|
+
}
|
|
1141
|
+
return segments;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* The bash statement with the zsh statement's glued-close readings added per
|
|
1146
|
+
* word (both have the same words). New token objects: the tokens are shared
|
|
1147
|
+
* between the two readings.
|
|
1148
|
+
*
|
|
1149
|
+
* @param {Array<object>} target
|
|
1150
|
+
* @param {Array<object>} source
|
|
1151
|
+
* @returns {Array<object>}
|
|
1152
|
+
*/
|
|
1153
|
+
function mergeCloseReadings(target, source) {
|
|
1154
|
+
return target.map((tok, i) => {
|
|
1155
|
+
const extra = source[i]?.closeReadings;
|
|
1156
|
+
if (!extra) return tok;
|
|
1157
|
+
const merged = [...new Set([...(tok.closeReadings ?? []), ...extra])];
|
|
1158
|
+
return { ...tok, closeReadings: merged };
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/** @param {Array<{ text: string, quoted: boolean }>} segment */
|
|
1163
|
+
function segmentKey(segment) {
|
|
1164
|
+
return JSON.stringify(segment.map((t) => [t.text, t.quoted]));
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* One reading of {@link splitSegments}. `cutSoftAlways`: whether an `always`
|
|
1169
|
+
* after a glued or one-word close starts a new statement (zsh) or stays a word
|
|
1170
|
+
* (bash). Every statement written after the first such cut is marked
|
|
1171
|
+
* `afterSoftCut`.
|
|
1172
|
+
*
|
|
1173
|
+
* @param {Array<{ text: string, quoted: boolean }>} tokens
|
|
1174
|
+
* @param {boolean} cutSoftAlways
|
|
1175
|
+
* @returns {{ segments: Array<Array<object>>, softCut: boolean }}
|
|
1176
|
+
*/
|
|
1177
|
+
function splitSegmentsReading(tokens, cutSoftAlways) {
|
|
1178
|
+
let softCut = false;
|
|
1179
|
+
// Marked when a statement is WRITTEN, not when `current` starts: keyword
|
|
1180
|
+
// peeling (`always { rm …`) replaces `current` in between.
|
|
717
1181
|
const segments = [];
|
|
1182
|
+
const pushSegment = (segment) => {
|
|
1183
|
+
if (segment.length > 0 && softCut) segment.afterSoftCut = true;
|
|
1184
|
+
segments.push(segment);
|
|
1185
|
+
};
|
|
718
1186
|
let current = [];
|
|
719
1187
|
const operators = new Set([';', '&&', '||', '|', '&']);
|
|
720
|
-
|
|
1188
|
+
// Open brace groups (#1707 point 1) — only a group opened in command
|
|
1189
|
+
// position counts, so `echo {a,b}` never arms the glued-`}` readings.
|
|
1190
|
+
let openGroups = 0;
|
|
1191
|
+
// The previous token closed a brace group — zsh's `always` may follow it:
|
|
1192
|
+
// 'hard' after a command-position `}`, 'soft' after a glued or one-word close.
|
|
1193
|
+
let closedGroup = null;
|
|
1194
|
+
// The next token is the count word of a command-position `repeat`.
|
|
1195
|
+
let repeatCount = false;
|
|
1196
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
1197
|
+
const tok = tokens[i];
|
|
1198
|
+
const afterClose = closedGroup;
|
|
1199
|
+
closedGroup = null;
|
|
721
1200
|
if (!tok.quoted && (tok.operator === 'newline' || operators.has(tok.text))) {
|
|
722
|
-
|
|
1201
|
+
pushSegment(current);
|
|
1202
|
+
current = [];
|
|
1203
|
+
repeatCount = false;
|
|
1204
|
+
continue;
|
|
1205
|
+
}
|
|
1206
|
+
if (repeatCount) { repeatCount = false; continue; }
|
|
1207
|
+
// zsh `repeat N body` (#1707 point 3): the keyword and its count are no
|
|
1208
|
+
// command; the body behind them runs N times. Only a count WORD counts: in
|
|
1209
|
+
// bash `repeat` is an ordinary command name and `repeat >CLAUDE.md`
|
|
1210
|
+
// truncates before the lookup fails, so a redirect or an operator behind it
|
|
1211
|
+
// keeps `repeat` the segment head and the redirect collected. A quoted count
|
|
1212
|
+
// is still one word — zsh 5.9 runs `repeat '2' cmd` and `repeat "$N" cmd`.
|
|
1213
|
+
const count = tokens[i + 1];
|
|
1214
|
+
if (current.length === 0 && !tok.quoted && tok.text === 'repeat' && count !== undefined &&
|
|
1215
|
+
!count.redirect && count.operator === undefined &&
|
|
1216
|
+
!operators.has(count.text)) {
|
|
1217
|
+
repeatCount = true;
|
|
1218
|
+
continue;
|
|
1219
|
+
}
|
|
1220
|
+
// zsh `{ try; } always { finally; }` (#1707 point 3): the `always` body runs
|
|
1221
|
+
// like any group, so `always` ends the statement before it.
|
|
1222
|
+
if (afterClose !== null && !tok.quoted && tok.text === 'always' &&
|
|
1223
|
+
(afterClose === 'hard' || cutSoftAlways)) {
|
|
1224
|
+
if (current.length > 0) pushSegment(current);
|
|
723
1225
|
current = [];
|
|
1226
|
+
if (afterClose === 'soft') softCut = true;
|
|
724
1227
|
continue;
|
|
725
1228
|
}
|
|
726
1229
|
// Reserved word in COMMAND POSITION (`current` still empty) → drop it, so
|
|
727
1230
|
// the NEXT token becomes the segment head and is itself judged in command
|
|
728
1231
|
// position (`do { rm …` peels both). A keyword anywhere else is an ordinary
|
|
729
1232
|
// argument and is kept verbatim (#1145).
|
|
730
|
-
|
|
1233
|
+
//
|
|
1234
|
+
// A function head (`f()`, `function f`, `function f()`) before its compound
|
|
1235
|
+
// body (#1679 point 1): the head is no command, and the body runs on every
|
|
1236
|
+
// call, so drop the head and judge the body in command position like a bare
|
|
1237
|
+
// brace group. Before, `f() { unlink <ledger>; }; f` resolved to verb `f()`.
|
|
1238
|
+
// `time` / `time -p` before a compound command is dropped the same way.
|
|
1239
|
+
if (!tok.quoted && COMMAND_POSITION_KEYWORDS.has(tok.text) &&
|
|
1240
|
+
(current.length === 0 || isCompoundPrefix(current))) {
|
|
1241
|
+
current = [];
|
|
1242
|
+
if (tok.text === '{') openGroups++;
|
|
1243
|
+
if (tok.text === '}') { openGroups = Math.max(0, openGroups - 1); closedGroup = 'hard'; }
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
// A `{` glued to the first word of a brace group (zsh, #1699 item 13 —
|
|
1247
|
+
// repeatedly and on the raw first character since #1707 point 2): drop the
|
|
1248
|
+
// openers, so the word behind them is the segment head.
|
|
1249
|
+
const glued = gluedBraceOpenerCount(tok);
|
|
1250
|
+
if (glued > 0 && (current.length === 0 || isCompoundPrefix(current))) {
|
|
1251
|
+
const { braceLead: _consumed, ...rest } = tok;
|
|
1252
|
+
current = [{ ...rest, text: tok.text.slice(glued) }];
|
|
1253
|
+
openGroups += glued;
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
// zsh's short function body `f() cmd` (#1707 point 3): zsh 5.9 runs
|
|
1257
|
+
// `f() unlink P; f`, so the head is dropped and `cmd` heads the segment.
|
|
1258
|
+
if (current.length === 1 && !current[0].quoted && FUNCTION_HEAD_RE.test(current[0].text) &&
|
|
1259
|
+
!tok.redirect) {
|
|
1260
|
+
current = [];
|
|
1261
|
+
}
|
|
1262
|
+
// Outside a group `gluedCloseReadings` is []; inside one, a glued `}` word
|
|
1263
|
+
// gets its stripped reading but does NOT lower `openGroups`: `${HOME}`,
|
|
1264
|
+
// `{a,b}`, `"a}"` look the same and close nothing in zsh, so every later
|
|
1265
|
+
// glued `}` keeps its reading (fail-closed, see gluedCloseReadings). It may
|
|
1266
|
+
// still close a group, so an `always` may follow it.
|
|
1267
|
+
const readings = !tok.redirect && tok.operator === undefined
|
|
1268
|
+
? gluedCloseReadings(tok.text, openGroups)
|
|
1269
|
+
: [];
|
|
1270
|
+
if (readings.length > 0) {
|
|
1271
|
+
closedGroup = 'soft';
|
|
1272
|
+
current.push({ ...tok, closeReadings: readings });
|
|
731
1273
|
continue;
|
|
732
1274
|
}
|
|
1275
|
+
// A one-word group heading its statement (`{true} always {…}`) closes
|
|
1276
|
+
// itself, so an `always` may follow it as well.
|
|
1277
|
+
if (current.length === 0 && !tok.quoted && tok.text.length > 2 && tok.text[0] === '{' &&
|
|
1278
|
+
tok.text.endsWith('}')) {
|
|
1279
|
+
closedGroup = 'soft';
|
|
1280
|
+
}
|
|
733
1281
|
current.push(tok);
|
|
734
1282
|
}
|
|
735
|
-
|
|
736
|
-
return segments.filter((s) => s.length > 0);
|
|
1283
|
+
pushSegment(current);
|
|
1284
|
+
return { segments: segments.filter((s) => s.length > 0), softCut };
|
|
737
1285
|
}
|
|
738
1286
|
|
|
739
1287
|
// Public alias — the single source of truth for chain-segment splitting shared
|
|
@@ -1394,8 +1942,12 @@ function collectRedirectTargets(segments, out, depth, budget) {
|
|
|
1394
1942
|
// Variable indirection (`> "$X"`), command substitution (`> $(cmd)` /
|
|
1395
1943
|
// backticks), or a missing operand: fail-visible, never guess (#983).
|
|
1396
1944
|
out.push({ target: null, mode, fd, unresolved: true });
|
|
1397
|
-
|
|
1398
|
-
|
|
1945
|
+
continue;
|
|
1946
|
+
}
|
|
1947
|
+
// A glued group-closing `}` (`{echo x >CLAUDE.md}`) — both readings are
|
|
1948
|
+
// judged (#1707 point 1, see gluedCloseReadings).
|
|
1949
|
+
for (const text of [operand.text, ...(operand.closeReadings ?? [])]) {
|
|
1950
|
+
out.push({ target: text, mode, fd });
|
|
1399
1951
|
}
|
|
1400
1952
|
}
|
|
1401
1953
|
|
|
@@ -1975,7 +2527,11 @@ function collectDeleteTargets(segments, out, depth, budget) {
|
|
|
1975
2527
|
// this segment — same rule commandHasRecursiveForceRm applies. An
|
|
1976
2528
|
// interpreter's quoted payload is reached through the recursion below
|
|
1977
2529
|
// instead, where it is re-tokenized as the command it really is.
|
|
1978
|
-
|
|
2530
|
+
// A quoted SINGLE word is no such literal (#1707 point 2): `"unlink" P`
|
|
2531
|
+
// and `{"unlink" P;}` run unlink, the quotes only vanish. Only a quoted
|
|
2532
|
+
// token with whitespace in it — a payload, never one argv word that
|
|
2533
|
+
// could still resolve to the verb — stays inert.
|
|
2534
|
+
if (segment[reading.index].quoted && /\s/.test(segment[reading.index].text)) continue;
|
|
1979
2535
|
|
|
1980
2536
|
let seenDashDash = false;
|
|
1981
2537
|
for (let i = reading.index + 1; i < segment.length; i++) {
|
|
@@ -1998,19 +2554,23 @@ function collectDeleteTargets(segments, out, depth, budget) {
|
|
|
1998
2554
|
// where a value-taking reading would have swallowed it silently.
|
|
1999
2555
|
if (!seenDashDash && !tok.quoted && tok.text.startsWith('-') && tok.text !== '-') continue;
|
|
2000
2556
|
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2557
|
+
// A word with a glued group-closing `}` is judged in BOTH readings
|
|
2558
|
+
// (#1707 point 1, see gluedCloseReadings): `{rm -r .orchestrator}`.
|
|
2559
|
+
for (const text of tok.closeReadings ? [tok.text, ...tok.closeReadings] : [tok.text]) {
|
|
2560
|
+
const key = `${reading.verb}\u0000${text}`;
|
|
2561
|
+
if (seen.has(key)) continue;
|
|
2562
|
+
seen.add(key);
|
|
2563
|
+
|
|
2564
|
+
if (/[$`]/.test(text)) {
|
|
2565
|
+
// Variable indirection (`rm -f "$LEDGER"`) or command substitution:
|
|
2566
|
+
// fail-visible, never guess (#983 rule, same direction).
|
|
2567
|
+
out.push({
|
|
2568
|
+
target: null, verb: reading.verb, unresolved: true, reason: 'variable/substitution',
|
|
2569
|
+
});
|
|
2570
|
+
continue;
|
|
2571
|
+
}
|
|
2572
|
+
out.push({ target: text, verb: reading.verb });
|
|
2012
2573
|
}
|
|
2013
|
-
out.push({ target: tok.text, verb: reading.verb });
|
|
2014
2574
|
}
|
|
2015
2575
|
}
|
|
2016
2576
|
|