session-orchestrator 3.17.0 → 3.20.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 +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +185 -412
- package/README.md +12 -9
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +9 -4
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +794 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +245 -50
- package/docs/session-config-template.md +39 -26
- package/hooks/_lib/guard-source-loader.mjs +680 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +226 -19
- package/hooks/enforce-scope.mjs +133 -9
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +30 -4
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +616 -164
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +6 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/emit-session.mjs +3 -40
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1273 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +17 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +26 -40
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/learnings/select.mjs +550 -0
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +128 -24
- package/scripts/lib/reconcile/engine.mjs +156 -54
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +95 -1
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +568 -145
- package/scripts/lib/session-close-backfill.mjs +63 -8
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +244 -36
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +549 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +17 -3
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +218 -16
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/release.mjs +534 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/brainstorm/soul.md +47 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +117 -18
- package/skills/gitlab-ops/SKILL.md +25 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/grill/soul.md +44 -1
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +17 -1
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/plan/soul.md +46 -3
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +34 -36
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +32 -2
- package/skills/session-end/plan-verification.md +6 -7
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +75 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/session-start/soul.md +41 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +108 -7
- package/skills/wave-executor/SKILL.md +6 -7
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +198 -80
- package/templates/_shared/loop.md +4 -4
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
import { existsSync, readFileSync } from 'node:fs';
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
|
|
17
|
+
import { tokenizeCommand, splitChainSegments, resolveSegmentVerb } from './command-blocker.mjs';
|
|
18
|
+
|
|
17
19
|
/**
|
|
18
20
|
* Find the wave-scope.json file for the given project root.
|
|
19
21
|
*
|
|
@@ -223,6 +225,285 @@ export function assertFileScopeSubset(fileScope, allowedPaths) {
|
|
|
223
225
|
return { ok: missing.length === 0, missing };
|
|
224
226
|
}
|
|
225
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Default production→test sibling rules (#970). `source` is matched with
|
|
230
|
+
* {@link pathMatchesPattern}; `sibling` is a TEMPLATE expanded with `{basename}`
|
|
231
|
+
* (filename without its extension) and `{dir}` (dirname, no trailing slash).
|
|
232
|
+
*
|
|
233
|
+
* This repo's layout only. Consumer repos override via `opts.rules` — observed
|
|
234
|
+
* shapes there are `{ source: '**\/*.ts', sibling: '{dir}/{basename}.test.*' }`,
|
|
235
|
+
* `{ source: '**\/*.ts', sibling: '{dir}/__tests__/**' }` and
|
|
236
|
+
* `{ source: 'supabase/migrations/**', sibling: 'supabase/tests/**' }`.
|
|
237
|
+
*
|
|
238
|
+
* @type {ReadonlyArray<{source: string, sibling: string}>}
|
|
239
|
+
*/
|
|
240
|
+
export const DEFAULT_TEST_SIBLING_RULES = Object.freeze([
|
|
241
|
+
Object.freeze({ source: '**/*.mjs', sibling: 'tests/**/{basename}*.test.mjs' }),
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Patterns that identify an entry as ALREADY test-side. Used for the
|
|
246
|
+
* no-inverse-expansion rule and, transitively, for idempotency.
|
|
247
|
+
* @type {ReadonlyArray<string>}
|
|
248
|
+
*/
|
|
249
|
+
export const DEFAULT_TEST_PATH_PATTERNS = Object.freeze([
|
|
250
|
+
'tests/**',
|
|
251
|
+
'test/**',
|
|
252
|
+
'**/__tests__/**',
|
|
253
|
+
'**/*.test.*',
|
|
254
|
+
'**/*.spec.*',
|
|
255
|
+
]);
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The wave roles for which test-sibling expansion (#970) fires. THE list — the
|
|
259
|
+
* prose in `skills/wave-executor/wave-loop.md` § Scope Manifest and
|
|
260
|
+
* `.cursor/rules/030-wave-execution.mdc` describes this constant; it does not
|
|
261
|
+
* restate it. Canonical casing; comparison is case-insensitive (see
|
|
262
|
+
* {@link testSiblingExpansionApplies}).
|
|
263
|
+
*
|
|
264
|
+
* Impl-Core / Impl-Polish are exactly where the incident occurred (an agent
|
|
265
|
+
* changes a production file and must update its test). Every other role is a
|
|
266
|
+
* documented non-expansion case: Discovery is deny-all `[]`, Quality phase 1
|
|
267
|
+
* (Simplification) MUST NOT gain test-write access, Quality phase 2 is already
|
|
268
|
+
* test-only (expansion is inert), Finalization writes no source.
|
|
269
|
+
*
|
|
270
|
+
* @type {ReadonlyArray<string>}
|
|
271
|
+
*/
|
|
272
|
+
export const TEST_SIBLING_EXPANSION_ROLES = Object.freeze(['Impl-Core', 'Impl-Polish']);
|
|
273
|
+
|
|
274
|
+
/** Lower-cased lookup set for {@link testSiblingExpansionApplies}. @type {ReadonlySet<string>} */
|
|
275
|
+
const EXPANSION_ROLE_KEYS = new Set(TEST_SIBLING_EXPANSION_ROLES.map((r) => r.toLowerCase()));
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Does test-sibling expansion (#970) apply for these options? The SINGLE
|
|
279
|
+
* predicate behind both {@link expandTestSiblings} (which grants the siblings)
|
|
280
|
+
* and {@link assertTestSiblingCoverage} (which requires them). They must agree:
|
|
281
|
+
* a check that demands coverage the expander never produced blocks a dispatch
|
|
282
|
+
* for a requirement nobody was told to satisfy.
|
|
283
|
+
*
|
|
284
|
+
* Precedence — `enabled` (explicit, BOTH directions) > `role` > fail-closed:
|
|
285
|
+
* 1. `enabled === false` → NO. The unconditional opt-out; nothing overrides it.
|
|
286
|
+
* 2. `enabled === true` → YES. Explicit opt-in, for callers whose role
|
|
287
|
+
* vocabulary is not this repo's (consumer repos) and for focused tests.
|
|
288
|
+
* 3. `role` present → YES iff it is in {@link TEST_SIBLING_EXPANSION_ROLES}
|
|
289
|
+
* (trimmed, case-insensitive — plans and `wave-scope.json` are written by
|
|
290
|
+
* LLM prose, and `impl-core` vs `Impl-Core` must not silently change
|
|
291
|
+
* behaviour).
|
|
292
|
+
* 4. Otherwise (role ABSENT, non-string, or UNRECOGNISED) → NO.
|
|
293
|
+
*
|
|
294
|
+
* ## Why absent-role fails CLOSED (the load-bearing choice)
|
|
295
|
+
* The two failure directions are not symmetric. Expansion wrongly OMITTED
|
|
296
|
+
* blocks an agent's write: it fails loudly, at the tool boundary, and the agent
|
|
297
|
+
* reports `blocked` — recoverable in one re-union. Expansion wrongly APPLIED
|
|
298
|
+
* silently hands write access to the test suite; in a Quality phase-1
|
|
299
|
+
* Simplification wave that is the "delete a dead export, then edit the test to
|
|
300
|
+
* match" failure mode, which defeats the very gate the wave exists to run.
|
|
301
|
+
* Silent-permissive is strictly worse than loud-restrictive, and this module's
|
|
302
|
+
* convention is already "cannot assert → treat as failure"
|
|
303
|
+
* (see {@link assertFileScopeSubset}). So a caller must NAME a role (or opt in
|
|
304
|
+
* explicitly); forgetting is not a grant.
|
|
305
|
+
*
|
|
306
|
+
* @param {{enabled?: boolean, role?: string}} [opts]
|
|
307
|
+
* @returns {boolean}
|
|
308
|
+
*/
|
|
309
|
+
export function testSiblingExpansionApplies(opts = {}) {
|
|
310
|
+
if (opts === null || typeof opts !== 'object') return false;
|
|
311
|
+
if (opts.enabled === false) return false;
|
|
312
|
+
if (opts.enabled === true) return true;
|
|
313
|
+
if (typeof opts.role !== 'string') return false;
|
|
314
|
+
return EXPANSION_ROLE_KEYS.has(opts.role.trim().toLowerCase());
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Expand a wave scope list with the TEST SIBLING of every concrete production
|
|
319
|
+
* file it grants (#970).
|
|
320
|
+
*
|
|
321
|
+
* ## The bug this closes
|
|
322
|
+
* An `allowedPaths` / agent `fileScope` entry that lists a production file
|
|
323
|
+
* WITHOUT its test sibling mechanically prevents the agent from updating that
|
|
324
|
+
* test. Observed three times in one consumer-repo session: a SQL regression test
|
|
325
|
+
* that could not be written, a cross-tenant security test left unwritable while
|
|
326
|
+
* its subject file stayed red, and a suite left red because the importing test
|
|
327
|
+
* lay outside every agent's scope. The scope guard enforced exactly the
|
|
328
|
+
* inconsistency the quality gate exists to catch.
|
|
329
|
+
*
|
|
330
|
+
* ## Why a GLOB and not a computed path
|
|
331
|
+
* The emitted sibling is `tests/**\/{basename}*.test.mjs`, never a concrete
|
|
332
|
+
* path. Measured over all 430 tracked production `.mjs` in this repo:
|
|
333
|
+
* a naive 1:1 mirror (`scripts/lib/X.mjs → tests/lib/X.test.mjs`) is right
|
|
334
|
+
* 301/430 = 70.0% (`hooks/_lib` 0/5, `scripts/ci/` 0/1); a same-basename test
|
|
335
|
+
* ANYWHERE under `tests/` is right 368/430 = 85.6%. The glob takes the 85.6%
|
|
336
|
+
* form, and its failure mode is HARMLESS — it grants write access to files that
|
|
337
|
+
* may not exist. A computed concrete path is wrong 30% of the time AND still
|
|
338
|
+
* denies the real test, which is the original bug wearing a new face.
|
|
339
|
+
*
|
|
340
|
+
* ## Required behaviours (each is a nameable regression)
|
|
341
|
+
* 1. `[]` → `[]` STRUCTURALLY. Discovery waves use an empty scope as a
|
|
342
|
+
* deliberate deny-all contract (#256 NO-OP); expansion must never be able to
|
|
343
|
+
* resurrect a write there.
|
|
344
|
+
* 2. IDEMPOTENT — `expand(expand(x))` deep-equals `expand(x)`. The #796
|
|
345
|
+
* re-union path rewrites the manifest mid-wave and the scope MUST NEVER
|
|
346
|
+
* shrink, so expansion has to compose with a re-run. Guaranteed by (4): every
|
|
347
|
+
* synthesized entry is itself a test path and is therefore inert on re-entry.
|
|
348
|
+
* 3. ABSOLUTE entries pass through untouched — a Gate-5b out-of-repo grant
|
|
349
|
+
* (`/Users/…/vault/**`) must not sprout a synthetic `tests/**` sibling
|
|
350
|
+
* outside the repo.
|
|
351
|
+
* 4. NO INVERSE EXPANSION — a test path never causes a production path to be
|
|
352
|
+
* added. Quality phase 2's scope is already test-only; expansion is inert there.
|
|
353
|
+
* 5. ROLE-GATED, fail-closed — the wave's `role` decides, via the shared
|
|
354
|
+
* predicate {@link testSiblingExpansionApplies}, and `opts.enabled` overrides
|
|
355
|
+
* it in both directions. Quality phase 1 (Simplification) must NOT expand, or
|
|
356
|
+
* simplification agents gain write access to the suite (the "delete a dead
|
|
357
|
+
* export, then edit the test to match" failure mode). An absent or
|
|
358
|
+
* unrecognised role does not expand — see the predicate for why.
|
|
359
|
+
*
|
|
360
|
+
* Glob source entries (`src/**`, `src/`) are NOT expanded: they have no single
|
|
361
|
+
* basename, and the incident shape is a concrete production FILE.
|
|
362
|
+
*
|
|
363
|
+
* APPEND-ONLY: the returned array starts with the input entries in their original
|
|
364
|
+
* order (deduplicated), then the synthesized siblings in first-seen order.
|
|
365
|
+
*
|
|
366
|
+
* Pure: no I/O, no filesystem probing, never throws. A non-array input returns `[]`.
|
|
367
|
+
*
|
|
368
|
+
* @param {string[]} paths — a wave `allowedPaths` union OR one agent's `fileScope`
|
|
369
|
+
* @param {{enabled?: boolean, role?: string,
|
|
370
|
+
* rules?: ReadonlyArray<{source: string, sibling: string}>,
|
|
371
|
+
* testPathPatterns?: ReadonlyArray<string>}} [opts]
|
|
372
|
+
* @returns {string[]} the input entries followed by synthesized test-sibling globs
|
|
373
|
+
*/
|
|
374
|
+
export function expandTestSiblings(paths, opts = {}) {
|
|
375
|
+
if (!Array.isArray(paths) || paths.length === 0) return []; // behaviour 1
|
|
376
|
+
const entries = paths.filter((e) => typeof e === 'string' && e.length > 0);
|
|
377
|
+
const seen = new Set(entries);
|
|
378
|
+
const out = [...new Set(entries)];
|
|
379
|
+
if (!testSiblingExpansionApplies(opts)) return out; // behaviour 5
|
|
380
|
+
|
|
381
|
+
for (const sibling of testSiblingsFor(entries, opts)) {
|
|
382
|
+
if (seen.has(sibling)) continue;
|
|
383
|
+
seen.add(sibling);
|
|
384
|
+
out.push(sibling);
|
|
385
|
+
}
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The synthesized test-sibling globs for a scope list, in first-seen order —
|
|
391
|
+
* WITHOUT the original entries. Shared by {@link expandTestSiblings} and the
|
|
392
|
+
* `--assert-subset` coverage check so both agree on what a sibling is.
|
|
393
|
+
*
|
|
394
|
+
* @param {string[]} entries — already-filtered non-empty string entries
|
|
395
|
+
* @param {{rules?: ReadonlyArray<{source: string, sibling: string}>,
|
|
396
|
+
* testPathPatterns?: ReadonlyArray<string>}} [opts]
|
|
397
|
+
* @returns {string[]}
|
|
398
|
+
*/
|
|
399
|
+
export function testSiblingsFor(entries, opts = {}) {
|
|
400
|
+
const rules = Array.isArray(opts.rules) ? opts.rules : DEFAULT_TEST_SIBLING_RULES;
|
|
401
|
+
const testPatterns = Array.isArray(opts.testPathPatterns)
|
|
402
|
+
? opts.testPathPatterns
|
|
403
|
+
: DEFAULT_TEST_PATH_PATTERNS;
|
|
404
|
+
const out = [];
|
|
405
|
+
const seen = new Set();
|
|
406
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
407
|
+
if (typeof entry !== 'string' || entry.length === 0) continue;
|
|
408
|
+
if (path.isAbsolute(entry)) continue; // behaviour 3
|
|
409
|
+
if (isGlobScopeEntry(entry)) continue; // no single basename to mirror
|
|
410
|
+
if (isTestPathEntry(entry, testPatterns)) continue; // behaviours 2 + 4
|
|
411
|
+
for (const rule of rules) {
|
|
412
|
+
if (!rule || typeof rule.source !== 'string' || typeof rule.sibling !== 'string') continue;
|
|
413
|
+
if (!pathMatchesPattern(entry, rule.source)) continue;
|
|
414
|
+
const sibling = renderSiblingTemplate(rule.sibling, entry);
|
|
415
|
+
if (!sibling || seen.has(sibling)) continue;
|
|
416
|
+
seen.add(sibling);
|
|
417
|
+
out.push(sibling);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return out;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Is this entry already test-side? Behaviours 2 + 4 both reduce to this check.
|
|
425
|
+
* @param {string} entry
|
|
426
|
+
* @param {ReadonlyArray<string>} testPatterns
|
|
427
|
+
* @returns {boolean}
|
|
428
|
+
*/
|
|
429
|
+
function isTestPathEntry(entry, testPatterns) {
|
|
430
|
+
return testPatterns.some((p) => pathMatchesPattern(entry, p));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Expand `{basename}` / `{dir}` in a sibling template against a production path.
|
|
435
|
+
* `{basename}` is the filename with its final extension removed.
|
|
436
|
+
* @param {string} template
|
|
437
|
+
* @param {string} entry
|
|
438
|
+
* @returns {string}
|
|
439
|
+
*/
|
|
440
|
+
function renderSiblingTemplate(template, entry) {
|
|
441
|
+
const base = entry.slice(entry.lastIndexOf('/') + 1);
|
|
442
|
+
const dot = base.lastIndexOf('.');
|
|
443
|
+
const basename = dot > 0 ? base.slice(0, dot) : base;
|
|
444
|
+
const slash = entry.lastIndexOf('/');
|
|
445
|
+
const dir = slash === -1 ? '.' : entry.slice(0, slash);
|
|
446
|
+
return template.replaceAll('{basename}', basename).replaceAll('{dir}', dir);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Assert that the wave's `allowedPaths` union grants the TEST SIBLING of every
|
|
451
|
+
* concrete production file in an agent's declared `fileScope` (#970).
|
|
452
|
+
*
|
|
453
|
+
* Companion to {@link assertFileScopeSubset}, wired into
|
|
454
|
+
* `validate-wave-scope.mjs --assert-subset --expand-test-siblings`. That flag is
|
|
455
|
+
* the one mechanical, fail-closed enforcement point in the dispatch pipeline (it
|
|
456
|
+
* exits 1); a warn there would be decorative, since warnings in that script do
|
|
457
|
+
* not affect the exit code.
|
|
458
|
+
*
|
|
459
|
+
* DIRECTION MATTERS: this expands the AGENT'S fileScope and requires the union to
|
|
460
|
+
* cover the result. Expanding `allowedPaths` instead would WEAKEN
|
|
461
|
+
* `--assert-subset` — its value is that it is a conservative over-approximation,
|
|
462
|
+
* and growing the union makes more things trivially "covered". This only ever
|
|
463
|
+
* ADDS a requirement; a manifest that passed the plain subset check can now fail,
|
|
464
|
+
* never the reverse.
|
|
465
|
+
*
|
|
466
|
+
* A synthesized glob `G` counts as covered when ANY of:
|
|
467
|
+
* 1. `G` is present verbatim in allowedPaths;
|
|
468
|
+
* 2. some allowedPaths entry MATCHES `G` — the union already grants a concrete
|
|
469
|
+
* file that IS a test sibling (`tests/lib/x.test.mjs` ⊨ `tests/**\/x*.test.mjs`);
|
|
470
|
+
* 3. `G`'s literal prefix is covered by an allowedPaths pattern — the same
|
|
471
|
+
* glob rule {@link assertFileScopeSubset} uses (a broad `tests/**` grant).
|
|
472
|
+
*
|
|
473
|
+
* ROLE-GATED BY THE SAME PREDICATE as the expander
|
|
474
|
+
* ({@link testSiblingExpansionApplies}). This is not symmetry for its own sake:
|
|
475
|
+
* a Quality phase-1 union is production files with tests deliberately excluded,
|
|
476
|
+
* so an ungated assertion would demand coverage that phase must never grant and
|
|
477
|
+
* would hard-block its dispatch. When the gate is off the assertion adds NO
|
|
478
|
+
* requirement (`{ ok: true }`) rather than failing closed — "expansion did not
|
|
479
|
+
* fire here" and "the union is deficient" are different facts, and only the
|
|
480
|
+
* second is a dispatch blocker. Non-array inputs still fail closed
|
|
481
|
+
* (`{ ok: false }`), matching the module convention.
|
|
482
|
+
*
|
|
483
|
+
* @param {string[]} fileScope — one agent's declared file scope
|
|
484
|
+
* @param {string[]} allowedPaths — the wave's allowedPaths union
|
|
485
|
+
* @param {object} [opts] — same shape as {@link expandTestSiblings} (incl. `role`)
|
|
486
|
+
* @returns {{ok: boolean, missing: string[]}} missing = uncovered sibling globs
|
|
487
|
+
*/
|
|
488
|
+
export function assertTestSiblingCoverage(fileScope, allowedPaths, opts = {}) {
|
|
489
|
+
if (!Array.isArray(fileScope) || !Array.isArray(allowedPaths)) {
|
|
490
|
+
return { ok: false, missing: [] };
|
|
491
|
+
}
|
|
492
|
+
if (!testSiblingExpansionApplies(opts)) return { ok: true, missing: [] };
|
|
493
|
+
const missing = [];
|
|
494
|
+
for (const sibling of testSiblingsFor(fileScope, opts)) {
|
|
495
|
+
const prefix = literalScopePrefix(sibling);
|
|
496
|
+
const covered = allowedPaths.some(
|
|
497
|
+
(p) =>
|
|
498
|
+
typeof p === 'string' &&
|
|
499
|
+
p.length > 0 &&
|
|
500
|
+
(p === sibling || pathMatchesPattern(p, sibling) || pathMatchesPattern(prefix, p)),
|
|
501
|
+
);
|
|
502
|
+
if (!covered) missing.push(sibling);
|
|
503
|
+
}
|
|
504
|
+
return { ok: missing.length === 0, missing };
|
|
505
|
+
}
|
|
506
|
+
|
|
226
507
|
/**
|
|
227
508
|
* Extract likely file-write TARGETS from a Bash command string (#800).
|
|
228
509
|
*
|
|
@@ -262,6 +543,55 @@ export function assertFileScopeSubset(fileScope, allowedPaths) {
|
|
|
262
543
|
* relatively, absolute where the command used an absolute path) and de-duplicated
|
|
263
544
|
* in first-seen order. The caller relativises + matches against allowedPaths.
|
|
264
545
|
*
|
|
546
|
+
* ## Lexing is DELEGATED to the shared tokenizer (#970)
|
|
547
|
+
*
|
|
548
|
+
* This function used to carry its OWN Bash lexer (`tokenizeShellForWrites`) —
|
|
549
|
+
* the third in this repo that had to know about quoting. It knew nothing about
|
|
550
|
+
* comments, here-doc BODIES or ANSI-C `$'…'` quotes, so it carried the exact
|
|
551
|
+
* quote-desync defect #965 fixed in `tokenizeCommand`: one apostrophe in an
|
|
552
|
+
* ordinary shell comment (`# don't`) wedged it in single-quote state and every
|
|
553
|
+
* later `>` became quoted text. Measured before the fix — `# don't\necho x >
|
|
554
|
+
* src/secret.ts` returned `[]`, i.e. an out-of-scope write was INVISIBLE to the
|
|
555
|
+
* wave-scope write guard. Two here-doc and ANSI-C variants did the same.
|
|
556
|
+
*
|
|
557
|
+
* It now delegates to `tokenizeCommand` from `./command-blocker.mjs`, which
|
|
558
|
+
* already emits the `>`/`>>`/`<<`/`;`/`|`/`&` operator tokens this pass needs
|
|
559
|
+
* and is the single place comment / here-doc / quote semantics live. Layering
|
|
560
|
+
* holds: `command-blocker.mjs` imports NOTHING (measured), so the edge
|
|
561
|
+
* scope-gate → command-blocker adds no cycle, and both stay pure/no-I/O-at-
|
|
562
|
+
* import for the hook hot path. The barrel `hardening.mjs` is not involved (a
|
|
563
|
+
* module importing the barrel WOULD cycle).
|
|
564
|
+
*
|
|
565
|
+
* Two deltas the shared token stream does not carry, handled locally below:
|
|
566
|
+
* - `(`/`)`: `tokenizeCommand` keeps them inside word text (they are not
|
|
567
|
+
* control operators for its purpose). {@link peelSubshellParens} splits them
|
|
568
|
+
* back out as separators so `(echo x > y)` still yields `y` and
|
|
569
|
+
* `> >(cat)` still yields nothing.
|
|
570
|
+
* - here-doc BODY tokens arrive as ordinary quoted words. They are DATA, and a
|
|
571
|
+
* body is multi-line, so the whitespace skip rule in
|
|
572
|
+
* {@link shouldSkipWriteTarget} drops them. (The delimiter word itself now
|
|
573
|
+
* emits no token at all — an improvement: `tee out.txt <<EOF` used to report
|
|
574
|
+
* the literal `EOF` as a second write target.)
|
|
575
|
+
*
|
|
576
|
+
* ## Segment-first, wrapper-aware verb resolution (#996.2)
|
|
577
|
+
*
|
|
578
|
+
* The interpretation is SEGMENT-first: the token stream is split on shell chain
|
|
579
|
+
* operators / newlines by {@link splitChainSegments} BEFORE it is interpreted,
|
|
580
|
+
* and each segment's real command verb is resolved through the shared
|
|
581
|
+
* {@link resolveSegmentVerb} wrapper table. A lone `expectCommand` flag used to
|
|
582
|
+
* read the FIRST word of a segment as the command head, so a transparent wrapper
|
|
583
|
+
* (`sudo`, `env FOO=1`, `timeout 5`, `nice -n 10`, `command`, `/usr/bin/time`)
|
|
584
|
+
* consumed the head slot and the REAL verb (`tee`/`sed`/`dd`) landed in argument
|
|
585
|
+
* position where it was never matched — measured `sudo tee src/x.ts` → `[]`, a
|
|
586
|
+
* DETECTION LOSS. This is the same fail-open class #991 closed in the ledger
|
|
587
|
+
* guard, in a warn-only (#800) consumer. `resolveSegmentVerb` unwraps the
|
|
588
|
+
* wrappers and returns a BASENAME-normalized verb, so an absolute `/usr/bin/tee`
|
|
589
|
+
* resolves to `tee` for free. It ALSO reports wrapper-written file operands
|
|
590
|
+
* (`writesFile: true`, the #992 discriminator) — `/usr/bin/time -o FILE npm test`
|
|
591
|
+
* truncates FILE while the verb is `npm`, a write with no redirect operator and
|
|
592
|
+
* no tee/sed/dd head; those are harvested exactly as {@link extractRedirectTargets}
|
|
593
|
+
* does.
|
|
594
|
+
*
|
|
265
595
|
* Hook-safe: pure, deterministic, no I/O. Never throws — a non-string / empty
|
|
266
596
|
* input returns `[]`.
|
|
267
597
|
*
|
|
@@ -271,83 +601,123 @@ export function assertFileScopeSubset(fileScope, allowedPaths) {
|
|
|
271
601
|
export function extractBashWriteTargets(command) {
|
|
272
602
|
if (typeof command !== 'string' || command.length === 0) return [];
|
|
273
603
|
|
|
274
|
-
const tokens = tokenizeShellForWrites(command);
|
|
275
|
-
|
|
276
604
|
const out = [];
|
|
277
605
|
const seen = new Set();
|
|
278
|
-
const add = (value
|
|
279
|
-
if (shouldSkipWriteTarget(value
|
|
606
|
+
const add = (value) => {
|
|
607
|
+
if (shouldSkipWriteTarget(value)) return;
|
|
280
608
|
const v = value.replace(/^\.\//, '');
|
|
281
609
|
if (!v || seen.has(v)) return;
|
|
282
610
|
seen.add(v);
|
|
283
611
|
out.push(v);
|
|
284
612
|
};
|
|
285
613
|
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
614
|
+
// Segment-first (#996.2): split on shell chain operators / newlines BEFORE
|
|
615
|
+
// interpreting, then resolve each segment's REAL verb through the shared
|
|
616
|
+
// wrapper table. `out`/`seen`/`add` stay outside the loop so de-duplication
|
|
617
|
+
// and first-seen ordering hold across segments (`echo a > x; echo b >> x`).
|
|
618
|
+
for (const segment of splitChainSegments(tokenizeCommand(command))) {
|
|
619
|
+
// #996.2 namespace fix: resolve the verb in the SAME (paren-peeled,
|
|
620
|
+
// redirect-target-excluded) namespace the interpretation loop's `wordsSeen`
|
|
621
|
+
// counter walks below — see {@link verbResolutionSegment}. Passing the RAW
|
|
622
|
+
// segment mis-resolved a LEADING redirect (`> a.txt tee b.ts` → verb `>`) or a
|
|
623
|
+
// LEADING subshell paren (`(tee inner.ts)` → verb `(tee`): mode never became
|
|
624
|
+
// `tee`/`sed`/`dd` and the `index + 1` head-skip then swallowed the real
|
|
625
|
+
// write target. For A5's 10 wrapper forms (no leading redirect/paren) the view
|
|
626
|
+
// is a no-op, so verb/index/wrapperArgs — incl. the #992 `writesFile` marks —
|
|
627
|
+
// are byte-identical to the raw call.
|
|
628
|
+
const { verb, index, wrapperArgs } = resolveSegmentVerb(verbResolutionSegment(segment));
|
|
629
|
+
const tokens = classifyShellTokens(segment);
|
|
630
|
+
|
|
631
|
+
// A wrapper can WRITE a file through its OWN option, with no redirect and no
|
|
632
|
+
// tee/sed/dd verb: `/usr/bin/time -o src/report.txt npm test` truncates
|
|
633
|
+
// src/report.txt while the resolved verb is `npm`. `resolveSegmentVerb` marks
|
|
634
|
+
// exactly those operands `writesFile: true` (the #992 discriminator that
|
|
635
|
+
// keeps `nice -n 10` / `sudo -u root` operands OUT). Harvest them exactly as
|
|
636
|
+
// collectRedirectTargets does — the write is otherwise invisible to this pass.
|
|
637
|
+
for (const wa of wrapperArgs) {
|
|
638
|
+
if (wa.writesFile === true && typeof wa.value === 'string') add(wa.value);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// `mode` tracks a command-head that owns following args (tee/sed/dd). It is
|
|
642
|
+
// set ONCE per segment from the resolved verb, which `resolveSegmentVerb`
|
|
643
|
+
// returns basename-normalized — so an absolute `/usr/bin/tee` resolves to
|
|
644
|
+
// `tee` for free. `pendingRedirect` marks that the NEXT word is a redirect
|
|
645
|
+
// target; `heredocOpen` that a here-doc BODY word (DATA) follows.
|
|
646
|
+
const mode = verb === 'tee' || verb === 'sed' || verb === 'dd' ? verb : null;
|
|
647
|
+
let pendingRedirect = false;
|
|
648
|
+
let heredocOpen = false;
|
|
649
|
+
const sedArgs = []; // classified word tokens collected for a `sed -i` head
|
|
650
|
+
let sedInPlace = false;
|
|
651
|
+
|
|
652
|
+
// THE ONE CARE POINT of the #996.2 refactor: the first `index + 1` WORD
|
|
653
|
+
// tokens that REACH the `wordsSeen` counter are the wrapper chain + the verb
|
|
654
|
+
// itself, never file arguments. `index` now comes from
|
|
655
|
+
// {@link verbResolutionSegment}, which resolves the verb in the SAME namespace
|
|
656
|
+
// this loop walks — paren-peeled words with redirect targets removed — so
|
|
657
|
+
// `index` is 1:1 with the `word` tokens that reach the counter below (a
|
|
658
|
+
// leading redirect or subshell paren no longer shifts it out of alignment).
|
|
659
|
+
// `peelSubshellParens` emits AT MOST one `word` per raw token.
|
|
660
|
+
const headWordsToSkip = index + 1;
|
|
661
|
+
let wordsSeen = 0;
|
|
662
|
+
|
|
663
|
+
for (const tk of tokens) {
|
|
664
|
+
if (tk.type === 'redirect') {
|
|
665
|
+
pendingRedirect = true;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
if (tk.type === 'heredoc') {
|
|
669
|
+
// here-doc: the delimiter emits no token; the BODY arrives later as data
|
|
670
|
+
pendingRedirect = false;
|
|
671
|
+
heredocOpen = true;
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
if (tk.type === 'in') {
|
|
675
|
+
// input redirect / here-string — not a write target
|
|
676
|
+
pendingRedirect = false;
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
if (tk.type === 'sep') {
|
|
680
|
+
// Within a segment the ONLY seps are peeled subshell parens — chain
|
|
681
|
+
// operators were already consumed by splitChainSegments. Reset only the
|
|
682
|
+
// pending redirect so `> >(cat)` stays inert (without it the peeled `cat`
|
|
683
|
+
// reads as the redirect target); mode/heredoc reset live at segment end.
|
|
684
|
+
pendingRedirect = false;
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
// word token
|
|
688
|
+
if (pendingRedirect) {
|
|
689
|
+
add(tk.value);
|
|
690
|
+
pendingRedirect = false;
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
if (wordsSeen < headWordsToSkip) {
|
|
694
|
+
wordsSeen++; // wrapper chain + verb head — not a file argument
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
// subsequent argument words, interpreted per active command-head mode
|
|
698
|
+
if (heredocOpen) continue; // DATA, not a file argument
|
|
699
|
+
if (mode === 'tee') {
|
|
700
|
+
if (!isShellFlag(tk.value)) add(tk.value);
|
|
701
|
+
} else if (mode === 'sed') {
|
|
702
|
+
if (/^-i/.test(tk.value)) sedInPlace = true;
|
|
703
|
+
sedArgs.push(tk);
|
|
704
|
+
} else if (mode === 'dd') {
|
|
705
|
+
if (tk.value.startsWith('of=')) add(tk.value.slice(3));
|
|
706
|
+
}
|
|
707
|
+
}
|
|
294
708
|
|
|
295
|
-
|
|
709
|
+
// Segment end: flush a pending `sed -i` file argument (last non-flag arg).
|
|
710
|
+
// Only reached once per segment now that each segment carries a single verb,
|
|
711
|
+
// so the former sep-branch / defensive flushes are unnecessary.
|
|
296
712
|
if (mode === 'sed' && sedInPlace) {
|
|
297
713
|
for (let i = sedArgs.length - 1; i >= 0; i--) {
|
|
298
714
|
if (!isShellFlag(sedArgs[i].value)) {
|
|
299
|
-
add(sedArgs[i].value
|
|
715
|
+
add(sedArgs[i].value);
|
|
300
716
|
break;
|
|
301
717
|
}
|
|
302
718
|
}
|
|
303
719
|
}
|
|
304
|
-
sedArgs = [];
|
|
305
|
-
sedInPlace = false;
|
|
306
|
-
};
|
|
307
|
-
|
|
308
|
-
for (const tk of tokens) {
|
|
309
|
-
if (tk.type === 'redirect') {
|
|
310
|
-
pendingRedirect = true;
|
|
311
|
-
continue;
|
|
312
|
-
}
|
|
313
|
-
if (tk.type === 'in') {
|
|
314
|
-
// input redirect / heredoc delimiter — not a write target
|
|
315
|
-
pendingRedirect = false;
|
|
316
|
-
continue;
|
|
317
|
-
}
|
|
318
|
-
if (tk.type === 'sep') {
|
|
319
|
-
flushSed();
|
|
320
|
-
mode = null;
|
|
321
|
-
pendingRedirect = false;
|
|
322
|
-
expectCommand = true;
|
|
323
|
-
continue;
|
|
324
|
-
}
|
|
325
|
-
// word token
|
|
326
|
-
if (pendingRedirect) {
|
|
327
|
-
add(tk.value, tk.hadSpace);
|
|
328
|
-
pendingRedirect = false;
|
|
329
|
-
continue;
|
|
330
|
-
}
|
|
331
|
-
if (expectCommand) {
|
|
332
|
-
expectCommand = false;
|
|
333
|
-
flushSed(); // flush any prior sed segment defensively
|
|
334
|
-
if (tk.value === 'tee') { mode = 'tee'; continue; }
|
|
335
|
-
if (tk.value === 'sed') { mode = 'sed'; continue; }
|
|
336
|
-
if (tk.value === 'dd') { mode = 'dd'; continue; }
|
|
337
|
-
mode = null;
|
|
338
|
-
continue;
|
|
339
|
-
}
|
|
340
|
-
// subsequent argument words, interpreted per active command-head mode
|
|
341
|
-
if (mode === 'tee') {
|
|
342
|
-
if (!isShellFlag(tk.value)) add(tk.value, tk.hadSpace);
|
|
343
|
-
} else if (mode === 'sed') {
|
|
344
|
-
if (/^-i/.test(tk.value)) sedInPlace = true;
|
|
345
|
-
sedArgs.push(tk);
|
|
346
|
-
} else if (mode === 'dd') {
|
|
347
|
-
if (tk.value.startsWith('of=')) add(tk.value.slice(3), tk.hadSpace);
|
|
348
|
-
}
|
|
349
720
|
}
|
|
350
|
-
flushSed();
|
|
351
721
|
|
|
352
722
|
return out;
|
|
353
723
|
}
|
|
@@ -365,13 +735,15 @@ function isShellFlag(v) {
|
|
|
365
735
|
/**
|
|
366
736
|
* Skip-rule gate for a candidate write target — see the documented skip list on
|
|
367
737
|
* {@link extractBashWriteTargets}. Returns true when the candidate must be dropped.
|
|
368
|
-
* @param {string} value — unquoted target text
|
|
369
|
-
* @param {boolean} hadSpace — true if the source token was quoted AND contained a space
|
|
738
|
+
* @param {string} value — unquoted target text (quotes already stripped by the lexer)
|
|
370
739
|
* @returns {boolean}
|
|
371
740
|
*/
|
|
372
|
-
function shouldSkipWriteTarget(value
|
|
741
|
+
function shouldSkipWriteTarget(value) {
|
|
373
742
|
if (typeof value !== 'string' || value.length === 0) return true;
|
|
374
|
-
|
|
743
|
+
// Any embedded whitespace: a quoted-with-space path (best-effort — far more
|
|
744
|
+
// likely a quoting artefact than a real wave-scoped file) and, since #970, a
|
|
745
|
+
// multi-line here-doc BODY token that reached an argument slot as DATA.
|
|
746
|
+
if (/\s/.test(value)) return true;
|
|
375
747
|
if (value.startsWith('$') || value.startsWith('~')) return true; // variable / expansion
|
|
376
748
|
if (value.includes('$')) return true; // any embedded expansion (covers ${TMPDIR})
|
|
377
749
|
if (value.includes('(') || value.includes(')')) return true; // process-sub remnants
|
|
@@ -380,99 +752,150 @@ function shouldSkipWriteTarget(value, hadSpace) {
|
|
|
380
752
|
return false;
|
|
381
753
|
}
|
|
382
754
|
|
|
755
|
+
/** Unquoted `tokenizeCommand` operator texts that break the current command. */
|
|
756
|
+
const SHELL_SEPARATOR_OPS = new Set([';', '|', '||', '&', '&&']);
|
|
757
|
+
/** Write redirects: `>`, `>>`, `>|`, and fd-prefixed forms (`2>`, `2>>`). */
|
|
758
|
+
const WRITE_REDIRECT_OP_RE = /^\d*(?:>>|>\|?)$/;
|
|
759
|
+
/** Here-doc operators `<<` / `<<-` — a DATA body token follows at the next newline. */
|
|
760
|
+
const HEREDOC_OP_RE = /^\d*<<-?$/;
|
|
761
|
+
/** Other input redirects `<` / `<<<` (here-string) — never a write target, no body. */
|
|
762
|
+
const INPUT_REDIRECT_OP_RE = /^\d*(?:<<<|<)$/;
|
|
763
|
+
|
|
383
764
|
/**
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
765
|
+
* Split leading `(` / trailing `)` off an UNQUOTED word token into standalone
|
|
766
|
+
* separator tokens.
|
|
767
|
+
*
|
|
768
|
+
* `tokenizeCommand` does not treat parentheses as control operators (it does not
|
|
769
|
+
* need to), so `(echo x > y)` arrives as words `(echo` … `y)`. Without this the
|
|
770
|
+
* subshell's redirect target would read as the literal `y)` and be dropped by the
|
|
771
|
+
* paren skip rule in {@link shouldSkipWriteTarget} — a silent DETECTION LOSS
|
|
772
|
+
* versus the pre-#970 lexer. Peeling restores it, and process substitution
|
|
773
|
+
* `> >(cat)` still yields nothing because the peeled `cat` lands in command-head
|
|
774
|
+
* position rather than redirect-target position.
|
|
775
|
+
*
|
|
776
|
+
* Quoted tokens are never peeled: `echo '(' x` must keep its literal paren.
|
|
777
|
+
*
|
|
778
|
+
* @param {{text: string, quoted: boolean}} tok
|
|
779
|
+
* @returns {Array<{type: string, value?: string}>}
|
|
780
|
+
*/
|
|
781
|
+
function peelSubshellParens(tok) {
|
|
782
|
+
if (tok.quoted) return [{ type: 'word', value: tok.text }];
|
|
783
|
+
let text = tok.text;
|
|
784
|
+
const out = [];
|
|
785
|
+
while (text.startsWith('(')) { out.push({ type: 'sep' }); text = text.slice(1); }
|
|
786
|
+
const trailing = [];
|
|
787
|
+
while (text.endsWith(')')) { trailing.push({ type: 'sep' }); text = text.slice(0, -1); }
|
|
788
|
+
if (text.length > 0 || (out.length === 0 && trailing.length === 0)) {
|
|
789
|
+
out.push({ type: 'word', value: text });
|
|
790
|
+
}
|
|
791
|
+
return out.concat(trailing);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Re-shape the shared `tokenizeCommand` output into the redirect/in/sep/word
|
|
796
|
+
* stream {@link extractBashWriteTargets} interprets. Operator classification is
|
|
797
|
+
* applied ONLY to unquoted tokens, so `echo '>' x` keeps its `>` as literal text.
|
|
798
|
+
*
|
|
799
|
+
* `&>` is not a distinct token here: `tokenizeCommand` deliberately lexes it as
|
|
800
|
+
* `&` followed by `>` (#965 Risk C). That still works — the redirect branch of the
|
|
801
|
+
* interpretation loop is checked before the command-head branch, so `cmd &> log`
|
|
802
|
+
* yields `log`. `2>&1` likewise yields nothing: the `&` separator resets the
|
|
803
|
+
* pending redirect before `1` is read.
|
|
389
804
|
*
|
|
390
805
|
* Token shapes: { type: 'redirect' } | { type: 'in' } | { type: 'sep' }
|
|
391
|
-
* | { type: 'word', value: string
|
|
806
|
+
* | { type: 'word', value: string }
|
|
392
807
|
*
|
|
393
|
-
* @param {string}
|
|
394
|
-
* @returns {Array<{type:string, value?:string
|
|
808
|
+
* @param {Array<{text: string, quoted: boolean}>} tokens
|
|
809
|
+
* @returns {Array<{type: string, value?: string}>}
|
|
395
810
|
*/
|
|
396
|
-
function
|
|
397
|
-
const
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
const next = command[i + 1];
|
|
405
|
-
|
|
406
|
-
if (isWs(c)) { i++; continue; }
|
|
407
|
-
|
|
408
|
-
// `&>` / `&>>` — redirect stdout+stderr to a file (write target follows)
|
|
409
|
-
if (c === '&' && next === '>') {
|
|
410
|
-
i += 2;
|
|
411
|
-
if (command[i] === '>') i++;
|
|
412
|
-
tokens.push({ type: 'redirect' });
|
|
413
|
-
continue;
|
|
414
|
-
}
|
|
415
|
-
// `&&` / `&` — command separators
|
|
416
|
-
if (c === '&') {
|
|
417
|
-
i += next === '&' ? 2 : 1;
|
|
418
|
-
tokens.push({ type: 'sep' });
|
|
419
|
-
continue;
|
|
420
|
-
}
|
|
421
|
-
// `>&` — fd duplication (NOT a file target); consume the dup + trailing fd/`-`
|
|
422
|
-
if (c === '>' && next === '&') {
|
|
423
|
-
i += 2;
|
|
424
|
-
while (i < n && (/[0-9]/.test(command[i]) || command[i] === '-')) i++;
|
|
425
|
-
continue; // no token — dup carries no write target
|
|
426
|
-
}
|
|
427
|
-
// `>(` — process substitution: leave the `>` inert; `(` is emitted as a sep
|
|
428
|
-
if (c === '>' && next === '(') { i++; continue; }
|
|
429
|
-
// `>>` / `>` — write redirects
|
|
430
|
-
if (c === '>') {
|
|
431
|
-
i += next === '>' ? 2 : 1;
|
|
432
|
-
tokens.push({ type: 'redirect' });
|
|
433
|
-
continue;
|
|
434
|
-
}
|
|
435
|
-
// `<<` / `<` — input redirects / heredoc delimiters (never a write target)
|
|
436
|
-
if (c === '<') {
|
|
437
|
-
i += next === '<' ? 2 : 1;
|
|
438
|
-
tokens.push({ type: 'in' });
|
|
439
|
-
continue;
|
|
811
|
+
function classifyShellTokens(tokens) {
|
|
812
|
+
const out = [];
|
|
813
|
+
for (const tok of tokens) {
|
|
814
|
+
if (!tok.quoted) {
|
|
815
|
+
if (SHELL_SEPARATOR_OPS.has(tok.text)) { out.push({ type: 'sep' }); continue; }
|
|
816
|
+
if (WRITE_REDIRECT_OP_RE.test(tok.text)) { out.push({ type: 'redirect' }); continue; }
|
|
817
|
+
if (HEREDOC_OP_RE.test(tok.text)) { out.push({ type: 'heredoc' }); continue; }
|
|
818
|
+
if (INPUT_REDIRECT_OP_RE.test(tok.text)) { out.push({ type: 'in' }); continue; }
|
|
440
819
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
820
|
+
out.push(...peelSubshellParens(tok));
|
|
821
|
+
}
|
|
822
|
+
return out;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Project a raw chain segment into the token list used for VERB resolution,
|
|
827
|
+
* in the SAME namespace {@link extractBashWriteTargets}'s interpretation loop
|
|
828
|
+
* walks with its `wordsSeen` counter (#996.2 namespace fix).
|
|
829
|
+
*
|
|
830
|
+
* {@link resolveSegmentVerb} resolves argv[0] from RAW tokens. Two segment shapes
|
|
831
|
+
* put a NON-verb token in raw position 0 and so mis-resolve the command head,
|
|
832
|
+
* losing the write target the pre-#996.2 pass had detected:
|
|
833
|
+
* - a LEADING write-redirect — `> a.txt tee b.ts` resolves the `>` operator as
|
|
834
|
+
* the "verb" (mode never becomes `tee`), and the loop's `index + 1` head-skip
|
|
835
|
+
* then swallows `tee`, dropping `b.ts`.
|
|
836
|
+
* - a LEADING subshell paren — `(tee inner.ts)` keeps the raw token `(tee`
|
|
837
|
+
* (`.replace(/^.*\//,'')` leaves it untouched, no `/`), so the wrapper table
|
|
838
|
+
* never matches: verb is the literal `(tee`, again mode-less. The classified
|
|
839
|
+
* stream peels the paren AFTER, so the raw verb index and the peeled word
|
|
840
|
+
* stream diverge.
|
|
841
|
+
*
|
|
842
|
+
* The projection makes both namespaces agree: peel leading/trailing parens off
|
|
843
|
+
* each unquoted token (matching {@link peelSubshellParens}' WORD output) and drop
|
|
844
|
+
* every write-redirect operator together with its target word — exactly the
|
|
845
|
+
* tokens the interpretation loop keeps OUT of `wordsSeen` (redirect operators are
|
|
846
|
+
* not `word`s; their targets are consumed by the `pendingRedirect` branch before
|
|
847
|
+
* the counter). The resulting segment's verb INDEX is then 1:1 with `wordsSeen`,
|
|
848
|
+
* so `index + 1` skips the wrapper chain + verb and no more.
|
|
849
|
+
*
|
|
850
|
+
* A5's 10 transparent-wrapper forms carry neither a leading redirect nor a paren,
|
|
851
|
+
* so this is a NO-OP for them: `resolveSegmentVerb`'s verb/index/wrapperArgs (incl.
|
|
852
|
+
* the #992 `writesFile` operand marks) are byte-identical to the raw-segment call.
|
|
853
|
+
*
|
|
854
|
+
* @param {Array<{ text: string, quoted: boolean }>} segment
|
|
855
|
+
* @returns {Array<{ text: string, quoted: boolean }>}
|
|
856
|
+
*/
|
|
857
|
+
function verbResolutionSegment(segment) {
|
|
858
|
+
const out = [];
|
|
859
|
+
let pendingRedirectTarget = false;
|
|
860
|
+
for (const tok of segment) {
|
|
861
|
+
if (!tok.quoted) {
|
|
862
|
+
// A write redirect consumes the NEXT token as its target — neither the
|
|
863
|
+
// operator nor its target reaches `wordsSeen`, so both must be absent here
|
|
864
|
+
// to keep the verb index aligned.
|
|
865
|
+
if (WRITE_REDIRECT_OP_RE.test(tok.text)) {
|
|
866
|
+
pendingRedirectTarget = true;
|
|
457
867
|
continue;
|
|
458
868
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
869
|
+
// heredoc / input-redirect operators carry no immediate target token that
|
|
870
|
+
// reaches `wordsSeen` (the here-doc delimiter emits nothing; an input target
|
|
871
|
+
// DOES reach the counter and so is kept) — drop only the operator itself.
|
|
872
|
+
if (HEREDOC_OP_RE.test(tok.text) || INPUT_REDIRECT_OP_RE.test(tok.text)) {
|
|
873
|
+
pendingRedirectTarget = false;
|
|
464
874
|
continue;
|
|
465
875
|
}
|
|
466
|
-
|
|
467
|
-
//
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
876
|
+
// Within a segment the only separator that can appear is an fd-dup `&`
|
|
877
|
+
// (e.g. `2>&1`), which classifyShellTokens also treats as a `sep` that
|
|
878
|
+
// resets the pending redirect — mirror that so the target after it stays.
|
|
879
|
+
if (SHELL_SEPARATOR_OPS.has(tok.text)) {
|
|
880
|
+
pendingRedirectTarget = false;
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
if (pendingRedirectTarget) {
|
|
885
|
+
pendingRedirectTarget = false;
|
|
886
|
+
continue; // this token is the dropped write-redirect target
|
|
471
887
|
}
|
|
472
|
-
|
|
888
|
+
if (tok.quoted) {
|
|
889
|
+
out.push(tok);
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
let text = tok.text;
|
|
893
|
+
while (text.startsWith('(')) text = text.slice(1);
|
|
894
|
+
while (text.endsWith(')')) text = text.slice(0, -1);
|
|
895
|
+
if (text.length === 0) continue; // pure paren = a separator, not a word
|
|
896
|
+
out.push({ text, quoted: false });
|
|
473
897
|
}
|
|
474
|
-
|
|
475
|
-
return tokens;
|
|
898
|
+
return out;
|
|
476
899
|
}
|
|
477
900
|
|
|
478
901
|
/**
|