session-orchestrator 3.16.0 → 3.19.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 +130 -412
- package/README.md +17 -12
- 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/docs/README.md +2 -1
- package/docs/USER-GUIDE.md +8 -3
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +444 -0
- package/docs/pi-setup.md +1 -1
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +306 -6
- package/docs/session-config-template.md +66 -3
- package/docs/telemetry/telemetry-claims.md +204 -0
- package/docs/telemetry.md +158 -0
- package/hooks/_lib/guard-source-loader.mjs +467 -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 +10 -2
- 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 +23 -0
- 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 +525 -160
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/skill-invocation-telemetry.mjs +109 -10
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +16 -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/check-doc-consistency.sh +17 -1
- package/scripts/compute-grounding-injection.sh +18 -3
- package/scripts/dialectic-deriver.mjs +7 -2
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/auto-dialectic.mjs +11 -2
- package/scripts/lib/auto-dream.mjs +16 -5
- 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/build-live-signals.mjs +7 -4
- 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 +1013 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/context-coverage.mjs +82 -0
- 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/moc-staleness.mjs +98 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config/worktree-orphans.mjs +138 -0
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +32 -0
- package/scripts/lib/context-coverage-banner.mjs +223 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +136 -30
- package/scripts/lib/dispatcher/rank.mjs +22 -8
- 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/evolve/autonomy-verdict.mjs +5 -0
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -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/harness-audit/categories/category4.mjs +22 -5
- 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/schema.mjs +43 -3
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/moc-staleness-banner.mjs +267 -0
- 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 +23 -4
- package/scripts/lib/reconcile/engine.mjs +147 -39
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- 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 +399 -98
- package/scripts/lib/session-close-backfill.mjs +61 -6
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/filters.mjs +88 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- 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/skill-health/join.mjs +35 -9
- 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/telemetry/anon-id.mjs +141 -0
- package/scripts/lib/telemetry/consent.mjs +299 -0
- package/scripts/lib/telemetry/paths.mjs +27 -0
- package/scripts/lib/telemetry/queue.mjs +287 -0
- package/scripts/lib/telemetry/schema.mjs +384 -0
- package/scripts/lib/telemetry/sync.mjs +312 -0
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-rules.mjs +217 -35
- 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-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/vault-status/board-writer.mjs +63 -5
- package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
- 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 +32 -6
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +51 -12
- package/scripts/release.mjs +534 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/telemetry.mjs +250 -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/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 +1 -0
- package/skills/gitlab-ops/SKILL.md +20 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +97 -0
- 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/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +108 -14
- 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 +2 -1
- package/skills/session-end/plan-verification.md +5 -2
- 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 +149 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/SKILL.md +1 -1
- package/skills/vault-sync/package-lock.json +3 -3
- package/skills/vault-sync/validator.mjs +228 -40
- package/skills/wave-executor/SKILL.md +5 -2
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +163 -10
- 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 } 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,36 @@ 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
|
+
*
|
|
265
576
|
* Hook-safe: pure, deterministic, no I/O. Never throws — a non-string / empty
|
|
266
577
|
* input returns `[]`.
|
|
267
578
|
*
|
|
@@ -271,12 +582,12 @@ export function assertFileScopeSubset(fileScope, allowedPaths) {
|
|
|
271
582
|
export function extractBashWriteTargets(command) {
|
|
272
583
|
if (typeof command !== 'string' || command.length === 0) return [];
|
|
273
584
|
|
|
274
|
-
const tokens =
|
|
585
|
+
const tokens = classifyShellTokens(tokenizeCommand(command));
|
|
275
586
|
|
|
276
587
|
const out = [];
|
|
277
588
|
const seen = new Set();
|
|
278
|
-
const add = (value
|
|
279
|
-
if (shouldSkipWriteTarget(value
|
|
589
|
+
const add = (value) => {
|
|
590
|
+
if (shouldSkipWriteTarget(value)) return;
|
|
280
591
|
const v = value.replace(/^\.\//, '');
|
|
281
592
|
if (!v || seen.has(v)) return;
|
|
282
593
|
seen.add(v);
|
|
@@ -288,6 +599,11 @@ export function extractBashWriteTargets(command) {
|
|
|
288
599
|
// token is a redirect target.
|
|
289
600
|
let mode = null; // null | 'tee' | 'sed' | 'dd'
|
|
290
601
|
let pendingRedirect = false;
|
|
602
|
+
// A here-doc was opened: `tokenizeCommand` will emit its BODY as an ordinary
|
|
603
|
+
// quoted word token, and a single-word body (`hello`) would otherwise be read
|
|
604
|
+
// as a `tee`/`sed`/`dd` file argument. Explicit `>` redirect targets stay
|
|
605
|
+
// accepted — those carry their own operator and cannot be body text.
|
|
606
|
+
let heredocOpen = false;
|
|
291
607
|
let expectCommand = true; // next word is the command head of this segment
|
|
292
608
|
let sedArgs = []; // { value } collected for a `sed` head
|
|
293
609
|
let sedInPlace = false;
|
|
@@ -296,7 +612,7 @@ export function extractBashWriteTargets(command) {
|
|
|
296
612
|
if (mode === 'sed' && sedInPlace) {
|
|
297
613
|
for (let i = sedArgs.length - 1; i >= 0; i--) {
|
|
298
614
|
if (!isShellFlag(sedArgs[i].value)) {
|
|
299
|
-
add(sedArgs[i].value
|
|
615
|
+
add(sedArgs[i].value);
|
|
300
616
|
break;
|
|
301
617
|
}
|
|
302
618
|
}
|
|
@@ -310,8 +626,14 @@ export function extractBashWriteTargets(command) {
|
|
|
310
626
|
pendingRedirect = true;
|
|
311
627
|
continue;
|
|
312
628
|
}
|
|
629
|
+
if (tk.type === 'heredoc') {
|
|
630
|
+
// here-doc: the delimiter emits no token; the BODY arrives later as data
|
|
631
|
+
pendingRedirect = false;
|
|
632
|
+
heredocOpen = true;
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
313
635
|
if (tk.type === 'in') {
|
|
314
|
-
// input redirect /
|
|
636
|
+
// input redirect / here-string — not a write target
|
|
315
637
|
pendingRedirect = false;
|
|
316
638
|
continue;
|
|
317
639
|
}
|
|
@@ -319,12 +641,13 @@ export function extractBashWriteTargets(command) {
|
|
|
319
641
|
flushSed();
|
|
320
642
|
mode = null;
|
|
321
643
|
pendingRedirect = false;
|
|
644
|
+
heredocOpen = false;
|
|
322
645
|
expectCommand = true;
|
|
323
646
|
continue;
|
|
324
647
|
}
|
|
325
648
|
// word token
|
|
326
649
|
if (pendingRedirect) {
|
|
327
|
-
add(tk.value
|
|
650
|
+
add(tk.value);
|
|
328
651
|
pendingRedirect = false;
|
|
329
652
|
continue;
|
|
330
653
|
}
|
|
@@ -338,13 +661,14 @@ export function extractBashWriteTargets(command) {
|
|
|
338
661
|
continue;
|
|
339
662
|
}
|
|
340
663
|
// subsequent argument words, interpreted per active command-head mode
|
|
664
|
+
if (heredocOpen) continue; // DATA, not a file argument
|
|
341
665
|
if (mode === 'tee') {
|
|
342
|
-
if (!isShellFlag(tk.value)) add(tk.value
|
|
666
|
+
if (!isShellFlag(tk.value)) add(tk.value);
|
|
343
667
|
} else if (mode === 'sed') {
|
|
344
668
|
if (/^-i/.test(tk.value)) sedInPlace = true;
|
|
345
669
|
sedArgs.push(tk);
|
|
346
670
|
} else if (mode === 'dd') {
|
|
347
|
-
if (tk.value.startsWith('of=')) add(tk.value.slice(3)
|
|
671
|
+
if (tk.value.startsWith('of=')) add(tk.value.slice(3));
|
|
348
672
|
}
|
|
349
673
|
}
|
|
350
674
|
flushSed();
|
|
@@ -365,13 +689,15 @@ function isShellFlag(v) {
|
|
|
365
689
|
/**
|
|
366
690
|
* Skip-rule gate for a candidate write target — see the documented skip list on
|
|
367
691
|
* {@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
|
|
692
|
+
* @param {string} value — unquoted target text (quotes already stripped by the lexer)
|
|
370
693
|
* @returns {boolean}
|
|
371
694
|
*/
|
|
372
|
-
function shouldSkipWriteTarget(value
|
|
695
|
+
function shouldSkipWriteTarget(value) {
|
|
373
696
|
if (typeof value !== 'string' || value.length === 0) return true;
|
|
374
|
-
|
|
697
|
+
// Any embedded whitespace: a quoted-with-space path (best-effort — far more
|
|
698
|
+
// likely a quoting artefact than a real wave-scoped file) and, since #970, a
|
|
699
|
+
// multi-line here-doc BODY token that reached an argument slot as DATA.
|
|
700
|
+
if (/\s/.test(value)) return true;
|
|
375
701
|
if (value.startsWith('$') || value.startsWith('~')) return true; // variable / expansion
|
|
376
702
|
if (value.includes('$')) return true; // any embedded expansion (covers ${TMPDIR})
|
|
377
703
|
if (value.includes('(') || value.includes(')')) return true; // process-sub remnants
|
|
@@ -380,99 +706,74 @@ function shouldSkipWriteTarget(value, hadSpace) {
|
|
|
380
706
|
return false;
|
|
381
707
|
}
|
|
382
708
|
|
|
709
|
+
/** Unquoted `tokenizeCommand` operator texts that break the current command. */
|
|
710
|
+
const SHELL_SEPARATOR_OPS = new Set([';', '|', '||', '&', '&&']);
|
|
711
|
+
/** Write redirects: `>`, `>>`, `>|`, and fd-prefixed forms (`2>`, `2>>`). */
|
|
712
|
+
const WRITE_REDIRECT_OP_RE = /^\d*(?:>>|>\|?)$/;
|
|
713
|
+
/** Here-doc operators `<<` / `<<-` — a DATA body token follows at the next newline. */
|
|
714
|
+
const HEREDOC_OP_RE = /^\d*<<-?$/;
|
|
715
|
+
/** Other input redirects `<` / `<<<` (here-string) — never a write target, no body. */
|
|
716
|
+
const INPUT_REDIRECT_OP_RE = /^\d*(?:<<<|<)$/;
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Split leading `(` / trailing `)` off an UNQUOTED word token into standalone
|
|
720
|
+
* separator tokens.
|
|
721
|
+
*
|
|
722
|
+
* `tokenizeCommand` does not treat parentheses as control operators (it does not
|
|
723
|
+
* need to), so `(echo x > y)` arrives as words `(echo` … `y)`. Without this the
|
|
724
|
+
* subshell's redirect target would read as the literal `y)` and be dropped by the
|
|
725
|
+
* paren skip rule in {@link shouldSkipWriteTarget} — a silent DETECTION LOSS
|
|
726
|
+
* versus the pre-#970 lexer. Peeling restores it, and process substitution
|
|
727
|
+
* `> >(cat)` still yields nothing because the peeled `cat` lands in command-head
|
|
728
|
+
* position rather than redirect-target position.
|
|
729
|
+
*
|
|
730
|
+
* Quoted tokens are never peeled: `echo '(' x` must keep its literal paren.
|
|
731
|
+
*
|
|
732
|
+
* @param {{text: string, quoted: boolean}} tok
|
|
733
|
+
* @returns {Array<{type: string, value?: string}>}
|
|
734
|
+
*/
|
|
735
|
+
function peelSubshellParens(tok) {
|
|
736
|
+
if (tok.quoted) return [{ type: 'word', value: tok.text }];
|
|
737
|
+
let text = tok.text;
|
|
738
|
+
const out = [];
|
|
739
|
+
while (text.startsWith('(')) { out.push({ type: 'sep' }); text = text.slice(1); }
|
|
740
|
+
const trailing = [];
|
|
741
|
+
while (text.endsWith(')')) { trailing.push({ type: 'sep' }); text = text.slice(0, -1); }
|
|
742
|
+
if (text.length > 0 || (out.length === 0 && trailing.length === 0)) {
|
|
743
|
+
out.push({ type: 'word', value: text });
|
|
744
|
+
}
|
|
745
|
+
return out.concat(trailing);
|
|
746
|
+
}
|
|
747
|
+
|
|
383
748
|
/**
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
749
|
+
* Re-shape the shared `tokenizeCommand` output into the redirect/in/sep/word
|
|
750
|
+
* stream {@link extractBashWriteTargets} interprets. Operator classification is
|
|
751
|
+
* applied ONLY to unquoted tokens, so `echo '>' x` keeps its `>` as literal text.
|
|
752
|
+
*
|
|
753
|
+
* `&>` is not a distinct token here: `tokenizeCommand` deliberately lexes it as
|
|
754
|
+
* `&` followed by `>` (#965 Risk C). That still works — the redirect branch of the
|
|
755
|
+
* interpretation loop is checked before the command-head branch, so `cmd &> log`
|
|
756
|
+
* yields `log`. `2>&1` likewise yields nothing: the `&` separator resets the
|
|
757
|
+
* pending redirect before `1` is read.
|
|
389
758
|
*
|
|
390
759
|
* Token shapes: { type: 'redirect' } | { type: 'in' } | { type: 'sep' }
|
|
391
|
-
* | { type: 'word', value: string
|
|
760
|
+
* | { type: 'word', value: string }
|
|
392
761
|
*
|
|
393
|
-
* @param {string}
|
|
394
|
-
* @returns {Array<{type:string, value?:string
|
|
762
|
+
* @param {Array<{text: string, quoted: boolean}>} tokens
|
|
763
|
+
* @returns {Array<{type: string, value?: string}>}
|
|
395
764
|
*/
|
|
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;
|
|
440
|
-
}
|
|
441
|
-
// `||` / `|` / `;` / `(` / `)` — separators (break the current command)
|
|
442
|
-
if (c === '|') { i += next === '|' ? 2 : 1; tokens.push({ type: 'sep' }); continue; }
|
|
443
|
-
if (c === ';') { i++; tokens.push({ type: 'sep' }); continue; }
|
|
444
|
-
if (c === '(' || c === ')') { i++; tokens.push({ type: 'sep' }); continue; }
|
|
445
|
-
|
|
446
|
-
// Otherwise: read a WORD, honouring single/double quotes (quotes stripped).
|
|
447
|
-
let value = '';
|
|
448
|
-
let quoted = false;
|
|
449
|
-
let hadSpace = false;
|
|
450
|
-
while (i < n) {
|
|
451
|
-
const ch = command[i];
|
|
452
|
-
if (ch === "'") {
|
|
453
|
-
quoted = true;
|
|
454
|
-
i++;
|
|
455
|
-
while (i < n && command[i] !== "'") { if (command[i] === ' ') hadSpace = true; value += command[i]; i++; }
|
|
456
|
-
i++; // closing quote (or EOF)
|
|
457
|
-
continue;
|
|
458
|
-
}
|
|
459
|
-
if (ch === '"') {
|
|
460
|
-
quoted = true;
|
|
461
|
-
i++;
|
|
462
|
-
while (i < n && command[i] !== '"') { if (command[i] === ' ') hadSpace = true; value += command[i]; i++; }
|
|
463
|
-
i++; // closing quote (or EOF)
|
|
464
|
-
continue;
|
|
465
|
-
}
|
|
466
|
-
if (isWs(ch)) break;
|
|
467
|
-
// unquoted operator chars end the word
|
|
468
|
-
if (ch === '>' || ch === '<' || ch === '|' || ch === ';' || ch === '&' || ch === '(' || ch === ')') break;
|
|
469
|
-
value += ch;
|
|
470
|
-
i++;
|
|
765
|
+
function classifyShellTokens(tokens) {
|
|
766
|
+
const out = [];
|
|
767
|
+
for (const tok of tokens) {
|
|
768
|
+
if (!tok.quoted) {
|
|
769
|
+
if (SHELL_SEPARATOR_OPS.has(tok.text)) { out.push({ type: 'sep' }); continue; }
|
|
770
|
+
if (WRITE_REDIRECT_OP_RE.test(tok.text)) { out.push({ type: 'redirect' }); continue; }
|
|
771
|
+
if (HEREDOC_OP_RE.test(tok.text)) { out.push({ type: 'heredoc' }); continue; }
|
|
772
|
+
if (INPUT_REDIRECT_OP_RE.test(tok.text)) { out.push({ type: 'in' }); continue; }
|
|
471
773
|
}
|
|
472
|
-
|
|
774
|
+
out.push(...peelSubshellParens(tok));
|
|
473
775
|
}
|
|
474
|
-
|
|
475
|
-
return tokens;
|
|
776
|
+
return out;
|
|
476
777
|
}
|
|
477
778
|
|
|
478
779
|
/**
|