@lannguyensi/harness 0.43.0 → 0.45.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/CHANGELOG.md +1248 -0
- package/dist/cli/adopt/derive.d.ts +16 -7
- package/dist/cli/adopt/derive.js +19 -2
- package/dist/cli/adopt/derive.js.map +1 -1
- package/dist/cli/adopt/index.js +18 -3
- package/dist/cli/adopt/index.js.map +1 -1
- package/dist/cli/apply/apply.d.ts +1 -0
- package/dist/cli/apply/apply.js +62 -6
- package/dist/cli/apply/apply.js.map +1 -1
- package/dist/cli/apply/generate-codex-config.js +5 -6
- package/dist/cli/apply/generate-codex-config.js.map +1 -1
- package/dist/cli/apply/generate-opencode-config.d.ts +58 -0
- package/dist/cli/apply/generate-opencode-config.js +210 -0
- package/dist/cli/apply/generate-opencode-config.js.map +1 -0
- package/dist/cli/apply/generate-settings.d.ts +90 -32
- package/dist/cli/apply/generate-settings.js +133 -10
- package/dist/cli/apply/generate-settings.js.map +1 -1
- package/dist/cli/apply/index.d.ts +2 -1
- package/dist/cli/apply/index.js +2 -1
- package/dist/cli/apply/index.js.map +1 -1
- package/dist/cli/approve/stdin-report.d.ts +15 -0
- package/dist/cli/approve/stdin-report.js +1 -1
- package/dist/cli/approve/stdin-report.js.map +1 -1
- package/dist/cli/approve/understanding.d.ts +75 -0
- package/dist/cli/approve/understanding.js +150 -2
- package/dist/cli/approve/understanding.js.map +1 -1
- package/dist/cli/audit.d.ts +1 -1
- package/dist/cli/audit.js +3 -2
- package/dist/cli/audit.js.map +1 -1
- package/dist/cli/doctor/claude-mcp.d.ts +19 -1
- package/dist/cli/doctor/claude-mcp.js +36 -10
- package/dist/cli/doctor/claude-mcp.js.map +1 -1
- package/dist/cli/doctor/codex.d.ts +3 -1
- package/dist/cli/doctor/codex.js +6 -9
- package/dist/cli/doctor/codex.js.map +1 -1
- package/dist/cli/doctor/format.js +94 -8
- package/dist/cli/doctor/format.js.map +1 -1
- package/dist/cli/doctor/index.d.ts +23 -0
- package/dist/cli/doctor/index.js +106 -2
- package/dist/cli/doctor/index.js.map +1 -1
- package/dist/cli/doctor/opencode.d.ts +24 -0
- package/dist/cli/doctor/opencode.js +107 -0
- package/dist/cli/doctor/opencode.js.map +1 -0
- package/dist/cli/doctor/target-checks.d.ts +11 -0
- package/dist/cli/doctor/target-checks.js +25 -0
- package/dist/cli/doctor/target-checks.js.map +1 -0
- package/dist/cli/doctor/toolchain-parity.d.ts +63 -0
- package/dist/cli/doctor/toolchain-parity.js +154 -0
- package/dist/cli/doctor/toolchain-parity.js.map +1 -0
- package/dist/cli/doctor/types.d.ts +85 -1
- package/dist/cli/doctor/types.js +1 -1
- package/dist/cli/doctor/types.js.map +1 -1
- package/dist/cli/doctor/understanding-mode-env.d.ts +39 -0
- package/dist/cli/doctor/understanding-mode-env.js +83 -0
- package/dist/cli/doctor/understanding-mode-env.js.map +1 -0
- package/dist/cli/dry-run.js +34 -13
- package/dist/cli/dry-run.js.map +1 -1
- package/dist/cli/explain.d.ts +1 -1
- package/dist/cli/explain.js +1 -1
- package/dist/cli/explain.js.map +1 -1
- package/dist/cli/index.js +62 -7
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init/composer.js +36 -13
- package/dist/cli/init/composer.js.map +1 -1
- package/dist/cli/init/interactive.d.ts +7 -4
- package/dist/cli/init/interactive.js +79 -27
- package/dist/cli/init/interactive.js.map +1 -1
- package/dist/cli/init/profiles.d.ts +2 -2
- package/dist/cli/init/profiles.js +32 -1
- package/dist/cli/init/profiles.js.map +1 -1
- package/dist/cli/init/templates.d.ts +2 -1
- package/dist/cli/init/templates.js +136 -24
- package/dist/cli/init/templates.js.map +1 -1
- package/dist/cli/pack/approve-escape.js +69 -8
- package/dist/cli/pack/approve-escape.js.map +1 -1
- package/dist/cli/pack/hook-branch-protection.js +1 -1
- package/dist/cli/pack/hook-branch-protection.js.map +1 -1
- package/dist/cli/pack/hook-codex-pre-tool-use.js +27 -1
- package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-post-merge-gate.js +44 -26
- package/dist/cli/pack/hook-post-merge-gate.js.map +1 -1
- package/dist/cli/pack/hook-pre-tool-use.js +91 -4
- package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-solution-acceptance.js +19 -2
- package/dist/cli/pack/hook-solution-acceptance.js.map +1 -1
- package/dist/cli/pack/understanding-report-schema-hint.d.ts +13 -0
- package/dist/cli/pack/understanding-report-schema-hint.js +52 -3
- package/dist/cli/pack/understanding-report-schema-hint.js.map +1 -1
- package/dist/cli/pause/index.js +1 -1
- package/dist/cli/pause/index.js.map +1 -1
- package/dist/cli/policy/intercept.d.ts +148 -0
- package/dist/cli/policy/intercept.js +538 -79
- package/dist/cli/policy/intercept.js.map +1 -1
- package/dist/cli/session-start/index.d.ts +12 -3
- package/dist/cli/session-start/index.js +67 -11
- package/dist/cli/session-start/index.js.map +1 -1
- package/dist/cli/session-start/stale-base-check.d.ts +71 -0
- package/dist/cli/session-start/stale-base-check.js +379 -0
- package/dist/cli/session-start/stale-base-check.js.map +1 -0
- package/dist/cli/session-start/toolchain-parity.d.ts +142 -8
- package/dist/cli/session-start/toolchain-parity.js +244 -36
- package/dist/cli/session-start/toolchain-parity.js.map +1 -1
- package/dist/cli/smoke/assertions.d.ts +9 -2
- package/dist/cli/smoke/assertions.js +9 -2
- package/dist/cli/smoke/assertions.js.map +1 -1
- package/dist/cli/uninstall/index.js +18 -2
- package/dist/cli/uninstall/index.js.map +1 -1
- package/dist/cli/validate/checks.d.ts +3 -1
- package/dist/cli/validate/checks.js +298 -3
- package/dist/cli/validate/checks.js.map +1 -1
- package/dist/io/claude-mcp.d.ts +122 -1
- package/dist/io/claude-mcp.js +214 -12
- package/dist/io/claude-mcp.js.map +1 -1
- package/dist/io/duration.js.map +1 -0
- package/dist/io/expand-home.js.map +1 -0
- package/dist/io/extract.js.map +1 -0
- package/dist/io/harness-lock.js +1 -1
- package/dist/io/harness-lock.js.map +1 -1
- package/dist/{runtime → io}/ledger-record.d.ts +1 -1
- package/dist/io/ledger-record.js.map +1 -0
- package/dist/policies/index.d.ts +2 -2
- package/dist/policies/index.js +2 -2
- package/dist/policies/index.js.map +1 -1
- package/dist/policies/ledger-client.js +10 -6
- package/dist/policies/ledger-client.js.map +1 -1
- package/dist/policies/producers.js +1 -1
- package/dist/policies/producers.js.map +1 -1
- package/dist/policies/requires.js +2 -2
- package/dist/policies/requires.js.map +1 -1
- package/dist/policy-packs/builtin/branch-protection.js +28 -3
- package/dist/policy-packs/builtin/branch-protection.js.map +1 -1
- package/dist/policy-packs/builtin/post-merge-gate-runtime.d.ts +90 -10
- package/dist/policy-packs/builtin/post-merge-gate-runtime.js +112 -16
- package/dist/policy-packs/builtin/post-merge-gate-runtime.js.map +1 -1
- package/dist/policy-packs/builtin/post-merge-gate.js +84 -31
- package/dist/policy-packs/builtin/post-merge-gate.js.map +1 -1
- package/dist/policy-packs/builtin/solution-acceptance-runtime.d.ts +152 -8
- package/dist/policy-packs/builtin/solution-acceptance-runtime.js +260 -13
- package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +1 -1
- package/dist/policy-packs/builtin/solution-acceptance.js +17 -3
- package/dist/policy-packs/builtin/solution-acceptance.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/active-claim.d.ts +25 -0
- package/dist/policy-packs/builtin/understanding-before-execution/active-claim.js +89 -0
- package/dist/policy-packs/builtin/understanding-before-execution/active-claim.js.map +1 -0
- package/dist/policy-packs/builtin/understanding-before-execution/index.d.ts +13 -0
- package/dist/policy-packs/builtin/understanding-before-execution/index.js +39 -0
- package/dist/policy-packs/builtin/understanding-before-execution/index.js.map +1 -0
- package/dist/policy-packs/builtin/understanding-before-execution/ledger.d.ts +25 -0
- package/dist/policy-packs/builtin/understanding-before-execution/ledger.js +56 -0
- package/dist/policy-packs/builtin/understanding-before-execution/ledger.js.map +1 -0
- package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.d.ts +20 -0
- package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.js +79 -0
- package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.js.map +1 -0
- package/dist/policy-packs/builtin/understanding-before-execution/markers.d.ts +103 -0
- package/dist/policy-packs/builtin/understanding-before-execution/markers.js +203 -0
- package/dist/policy-packs/builtin/understanding-before-execution/markers.js.map +1 -0
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.d.ts +177 -0
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js +309 -0
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js.map +1 -0
- package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.d.ts +106 -0
- package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js +209 -0
- package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js.map +1 -0
- package/dist/policy-packs/builtin/understanding-before-execution/task-markers.d.ts +78 -0
- package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js +185 -0
- package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js.map +1 -0
- package/dist/policy-packs/builtin/understanding-before-execution-runtime.d.ts +1 -536
- package/dist/policy-packs/builtin/understanding-before-execution-runtime.js +9 -1095
- package/dist/policy-packs/builtin/understanding-before-execution-runtime.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution.d.ts +109 -1
- package/dist/policy-packs/builtin/understanding-before-execution.js +183 -15
- package/dist/policy-packs/builtin/understanding-before-execution.js.map +1 -1
- package/dist/policy-packs/registry.d.ts +11 -0
- package/dist/policy-packs/registry.js +1 -1
- package/dist/policy-packs/registry.js.map +1 -1
- package/dist/policy-packs/runtime.d.ts +1 -1
- package/dist/policy-packs/runtime.js +10 -1
- package/dist/policy-packs/runtime.js.map +1 -1
- package/dist/runtime/agent-facing.js +1 -1
- package/dist/runtime/agent-facing.js.map +1 -1
- package/dist/runtime/bash-match-facts.d.ts +9 -7
- package/dist/runtime/bash-match-facts.js +9 -7
- package/dist/runtime/bash-match-facts.js.map +1 -1
- package/dist/runtime/bash-match-registry.d.ts +75 -38
- package/dist/runtime/bash-match-registry.js +279 -232
- package/dist/runtime/bash-match-registry.js.map +1 -1
- package/dist/runtime/bash-prefix-parse.d.ts +7 -0
- package/dist/runtime/bash-prefix-parse.js +205 -17
- package/dist/runtime/bash-prefix-parse.js.map +1 -1
- package/dist/runtime/command-normalize.d.ts +429 -4
- package/dist/runtime/command-normalize.js +1146 -67
- package/dist/runtime/command-normalize.js.map +1 -1
- package/dist/runtime/hermetic-spawn-guard.js +6 -0
- package/dist/runtime/hermetic-spawn-guard.js.map +1 -1
- package/dist/runtime/index.d.ts +2 -2
- package/dist/runtime/index.js +2 -2
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/intercept.d.ts +247 -4
- package/dist/runtime/intercept.js +553 -61
- package/dist/runtime/intercept.js.map +1 -1
- package/dist/runtime/ledger-add.js +1 -1
- package/dist/runtime/ledger-add.js.map +1 -1
- package/dist/runtime/read-only-bash.js +356 -26
- package/dist/runtime/read-only-bash.js.map +1 -1
- package/dist/runtime/shell-word.d.ts +21 -0
- package/dist/runtime/shell-word.js +242 -0
- package/dist/runtime/shell-word.js.map +1 -0
- package/dist/schema/doctor.d.ts +25 -0
- package/dist/schema/doctor.js +23 -0
- package/dist/schema/doctor.js.map +1 -0
- package/dist/schema/extract.js +1 -1
- package/dist/schema/extract.js.map +1 -1
- package/dist/schema/index.d.ts +76 -0
- package/dist/schema/index.js +9 -0
- package/dist/schema/index.js.map +1 -1
- package/dist/schema/requires.js +2 -2
- package/dist/schema/requires.js.map +1 -1
- package/dist/schema/risk.d.ts +7 -0
- package/dist/schema/risk.js +23 -0
- package/dist/schema/risk.js.map +1 -1
- package/dist/schema/stale-base-check.d.ts +18 -0
- package/dist/schema/stale-base-check.js +37 -0
- package/dist/schema/stale-base-check.js.map +1 -0
- package/dist/schema/toolchain-parity.d.ts +3 -0
- package/dist/schema/toolchain-parity.js +13 -0
- package/dist/schema/toolchain-parity.js.map +1 -1
- package/package.json +5 -2
- package/dist/policies/duration.js.map +0 -1
- package/dist/policies/extract.js.map +0 -1
- package/dist/runtime/expand-home.js.map +0 -1
- package/dist/runtime/ledger-record.js.map +0 -1
- /package/dist/{policies → io}/duration.d.ts +0 -0
- /package/dist/{policies → io}/duration.js +0 -0
- /package/dist/{runtime → io}/expand-home.d.ts +0 -0
- /package/dist/{runtime → io}/expand-home.js +0 -0
- /package/dist/{policies → io}/extract.d.ts +0 -0
- /package/dist/{policies → io}/extract.js +0 -0
- /package/dist/{runtime → io}/ledger-record.js +0 -0
|
@@ -8,7 +8,14 @@
|
|
|
8
8
|
// `bash_match` regex against the raw command first and, only on a raw
|
|
9
9
|
// miss, against this normalised form too (raw-OR-normalised, never raw-
|
|
10
10
|
// replaced-by-normalised), closing a class of silent trigger bypasses
|
|
11
|
-
// (see below)
|
|
11
|
+
// (see below) — and, only on a further miss of THIS form, against a
|
|
12
|
+
// SECOND, independent, ampersand-aware normalisation pass (`AMP_BOUNDARY_RE`
|
|
13
|
+
// / `normalizeCommandAmpAware` below, task aabbad63) that this header
|
|
14
|
+
// otherwise never mentions; see that function's own comment for its scope
|
|
15
|
+
// and why it carries no `targetDir`/`targetBase` of its own (fix round 1,
|
|
16
|
+
// finding F5 — added so a reader of this header, the module's index,
|
|
17
|
+
// learns a second pass exists at all instead of finding it only by
|
|
18
|
+
// reading past this point). `targetDir` / `targetBase` — the effective target
|
|
12
19
|
// directory of the command's own git invocation(s), when one can be
|
|
13
20
|
// named unambiguously — are NOT WIRED TO ANY GATE. An earlier version of
|
|
14
21
|
// this run used them to resolve the `${REPO}`/`${BRANCH}` policy
|
|
@@ -27,6 +34,38 @@
|
|
|
27
34
|
// `98ad072f`. Nothing below describes gate wiring; it describes what
|
|
28
35
|
// THIS MODULE computes, live or not.
|
|
29
36
|
//
|
|
37
|
+
// UPDATE (task `98ad072f`, run `2026-08-02-per-repo-gate-scoping-
|
|
38
|
+
// redesign`, T-002): the per-segment foundation named above now EXISTS as
|
|
39
|
+
// a real export — `segmentViewOf` / `CommandSegment`, defined below
|
|
40
|
+
// `normalizeCommand` — instead of being only a design intention. It
|
|
41
|
+
// exposes, per boundary-delimited segment, the canonicalised text, the
|
|
42
|
+
// segment's OWN explicit target, and an EFFECTIVE target composed with the
|
|
43
|
+
// last preceding `cd <path>` segment (see `CommandSegment`'s own doc
|
|
44
|
+
// comment for the composition rules and its own not-covered list, which
|
|
45
|
+
// is narrower in places than `targetDir`/`targetBase` above — e.g. no
|
|
46
|
+
// `VAR=value cd <path>` tolerance). `normalizeCommand`'s own `targetDir`/
|
|
47
|
+
// `targetBase` are UNCHANGED by this addition — byte-identical in every
|
|
48
|
+
// pre-existing test.
|
|
49
|
+
//
|
|
50
|
+
// UPDATE (task `98ad072f`, T-003, this run): `segmentViewOf` /
|
|
51
|
+
// `CommandSegment` — NOT `normalizeCommand`'s own `targetDir`/`targetBase`
|
|
52
|
+
// above, which stay exactly as unwired as before this slice — IS now
|
|
53
|
+
// wired to a gate: `src/runtime/intercept.ts`'s `attributeTriggerSegments`
|
|
54
|
+
// re-tests a matched policy's own `bash_match` regex against each
|
|
55
|
+
// segment's `text`, and, for a policy whose `requires:` uses
|
|
56
|
+
// `${REPO}`/`${BRANCH}`/`at_head`, resolves those builtins (and
|
|
57
|
+
// `currentHeadSha`) from the trigger-satisfying segment's
|
|
58
|
+
// `effectiveTarget` instead of the event's cwd — lazily, memoised per
|
|
59
|
+
// resolved path, never touching `resolverGit`/`riskContext`. Trust in
|
|
60
|
+
// `effectiveTarget` is UNIFORM — no distinction between a segment's own
|
|
61
|
+
// explicit target and one inherited from a preceding `cd` (orchestrator
|
|
62
|
+
// decision D-010: an initial revision distinguished the two and was
|
|
63
|
+
// rejected — bash itself draws no such distinction, a `cd <B> && <verb>`
|
|
64
|
+
// chain genuinely runs `<verb>` inside B). See that module's own comments
|
|
65
|
+
// (`attributeTriggerSegments`, `resolveAttributedContexts`) for the
|
|
66
|
+
// consumption rules; this module's OWN extraction and ambiguity rules
|
|
67
|
+
// below are unchanged by being consumed.
|
|
68
|
+
//
|
|
30
69
|
// MEASUREMENT RULE (task 47297478): before measuring THIS module's
|
|
31
70
|
// extraction across builds with an ad-hoc corpus, read the per-arm-gate
|
|
32
71
|
// design in scripts/measure-bash-prefix-parse.mjs — a corpus arm whose
|
|
@@ -35,7 +74,65 @@
|
|
|
35
74
|
// Note the tool itself measures ONLY bash-prefix-parse.ts's `cdTarget`
|
|
36
75
|
// extraction; this module's `normalized`/`targetDir`/`targetBase`
|
|
37
76
|
// outputs have no instrument yet, so cross-build claims about them need
|
|
38
|
-
// their own per-arm-gated measurement
|
|
77
|
+
// their own per-arm-gated measurement — that instrument now exists:
|
|
78
|
+
// scripts/measure-command-normalize.mjs (groundwork for task aabbad63, the
|
|
79
|
+
// BOUNDARY_RE bare-`&` fix below). It rebuilds a faithful superset of the
|
|
80
|
+
// reverted fix's 140-form quoted-value regression corpus and gates arm A
|
|
81
|
+
// on bash actually running the verb, per the same never-fold-into-a-zero
|
|
82
|
+
// discipline.
|
|
83
|
+
//
|
|
84
|
+
// UPDATE (task cf3dff51): closes the quoted-shell-boundary residual named
|
|
85
|
+
// in the "DELIBERATELY NOT SUPPORTED" list below (`VAR='a; b' git push` —
|
|
86
|
+
// task 13e55484's pinned gap). A HALT criterion, written before any
|
|
87
|
+
// implementation code, forced the closing mechanism into an ADDITIVE,
|
|
88
|
+
// fully independent third normalisation pass (`normalizeCommandQuoteAware`
|
|
89
|
+
// / `findNextBoundaryQuoteAware`, defined near `findNextBoundary` below)
|
|
90
|
+
// rather than an in-place edit of `BOUNDARY_RE` / `segmentAndCanonicalize`
|
|
91
|
+
// itself. First round of this task built and fully tested the new pass but
|
|
92
|
+
// left it UNWIRED (the wiring — a `policyMatchesEvent` fourth disjunct in
|
|
93
|
+
// `src/runtime/intercept.ts` — touched a file outside that round's
|
|
94
|
+
// assigned scope). The orchestrator EXTENDED the scope for a second round
|
|
95
|
+
// specifically to include that one wiring point (not a general re-open of
|
|
96
|
+
// scope), so `normalizeCommandQuoteAware` IS now `policyMatchesEvent`'s
|
|
97
|
+
// fourth arm, mirroring `normalizeCommandAmpAware`'s own wiring (task
|
|
98
|
+
// aabbad63) exactly — see that function's own doc comment and
|
|
99
|
+
// `src/runtime/intercept.ts`'s `policyMatchesEvent` for the wired shape.
|
|
100
|
+
// UPDATE (task f561e44c): `src/cli/dry-run.ts`'s own, independently-copied
|
|
101
|
+
// raw-OR-normalised-OR-amp-normalised matcher (the `harness dry-run` debug
|
|
102
|
+
// verb, which predicts `policyMatchesEvent`'s verdict without running it)
|
|
103
|
+
// was left out of the wiring above (named residual, not silently left
|
|
104
|
+
// implicit) and, as a result, under-predicted the quote-aware family. This
|
|
105
|
+
// follow-up task added the same fourth OR-arm to that matcher too, so
|
|
106
|
+
// `harness dry-run` and `policy intercept` now agree on this family — see
|
|
107
|
+
// that file's own comment for the fix and its parity-fixture test.
|
|
108
|
+
//
|
|
109
|
+
// THE HALT CRITERION (decided and written down before writing the first
|
|
110
|
+
// line of this pass's implementation, mirroring 13e55484's own "ONE-
|
|
111
|
+
// DIRECTIONAL GUARD" structure): quote-aware segmentation MUST be built as
|
|
112
|
+
// a NEW, independent function — never as an in-place edit of the EXISTING,
|
|
113
|
+
// already-wired primary pass (`BOUNDARY_RE` / `findNextBoundary` /
|
|
114
|
+
// `segmentAndCanonicalize`'s behaviour for `normalizeCommand`). If closing
|
|
115
|
+
// this residual is ever found to require editing that existing pass in
|
|
116
|
+
// place instead of adding a new one, HALT and fall back to documentation-
|
|
117
|
+
// only (pin the residual, as this header already does for its NOT-
|
|
118
|
+
// SUPPORTED siblings) rather than risk a two-directional regression. This
|
|
119
|
+
// is not a hypothetical: this task PROVED the trigger before writing any
|
|
120
|
+
// code, by reading `tests/runtime/command-normalize.test.ts`'s pinned
|
|
121
|
+
// "accepted over-block: a quoted assignment inside TEXT after a boundary
|
|
122
|
+
// char now matches" case — `echo "a; VAR='x y' git push"` — which matches
|
|
123
|
+
// TODAY only because `BOUNDARY_RE`'s quote-unawareness un-splits `VAR='x
|
|
124
|
+
// y' git push` into its own segment. An in-place quote-aware edit of the
|
|
125
|
+
// SAME pass would un-split it the other way (merge it back into the outer
|
|
126
|
+
// `echo "..."` segment, which is not itself a recognised invocation) and
|
|
127
|
+
// flip that already-accepted, already-WIRED pin from match to no-match —
|
|
128
|
+
// a genuine lost match under this module's own no-lost-matches discipline
|
|
129
|
+
// (see AMP_BOUNDARY_RE's own comment for why that discipline exists at
|
|
130
|
+
// all), not merely a lost false positive. Building a separate, additive
|
|
131
|
+
// pass instead means the existing primary pass — and every pin resting on
|
|
132
|
+
// it, including that exact one — stays byte-for-byte untouched; see
|
|
133
|
+
// `normalizeCommandQuoteAware`'s own comment for the differential
|
|
134
|
+
// measurement that confirms this by construction rather than by
|
|
135
|
+
// inspection alone.
|
|
39
136
|
//
|
|
40
137
|
// THE TRIGGER-MATCHING GAP THIS CLOSES: every `bash_match` policy trigger
|
|
41
138
|
// (`src/cli/init/templates.ts`, `docs/examples/full-manifest.yaml`) is a
|
|
@@ -221,13 +318,33 @@
|
|
|
221
318
|
// agent writes and then executes.
|
|
222
319
|
// - `pushd`/`popd`, and a `cd` inside a nested subshell
|
|
223
320
|
// (`(cd X && ...)`) — mirrors `bash-prefix-parse.ts`'s own scope.
|
|
224
|
-
// -
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
321
|
+
// - CLOSED (was: a shell-boundary character INSIDE a quoted assignment
|
|
322
|
+
// VALUE — `VAR='a; b' git push`, task 13e55484's pinned residual, no
|
|
323
|
+
// longer belongs in a NOT-SUPPORTED list). Task cf3dff51: a quote-
|
|
324
|
+
// aware THIRD normalisation pass, `normalizeCommandQuoteAware`
|
|
325
|
+
// (defined near `findNextBoundary` below), built as a new, additive
|
|
326
|
+
// pass rather than an in-place edit of `BOUNDARY_RE` /
|
|
327
|
+
// `segmentAndCanonicalize` (see the module header's HALT CRITERION
|
|
328
|
+
// paragraph and that function's own comment for why), now wired as
|
|
329
|
+
// `policyMatchesEvent`'s FOURTH arm (`src/runtime/intercept.ts`,
|
|
330
|
+
// mirroring `normalizeCommandAmpAware`'s own wiring, task aabbad63).
|
|
331
|
+
// Closes `;`, `|`, `&&`, `(`, and a literal newline, each with an
|
|
332
|
+
// internal whitespace split inside the quoted value (the exact
|
|
333
|
+
// `VAR='a; b' git push` shape) — measured 12/12 at the real
|
|
334
|
+
// `runInterceptCli` hook entry point (isolated harness home,
|
|
335
|
+
// PATH-shim-verified bash execution, two gated verbs). Over-block
|
|
336
|
+
// surface: MEASURED, not assumed, against an adversarial probe —
|
|
337
|
+
// every ADDITIONAL match this pass adds beyond raw /
|
|
338
|
+
// `normalizeCommand` / `normalizeCommandAmpAware` is a TRUE positive
|
|
339
|
+
// (a real invocation bash would actually execute); no NEW over-block
|
|
340
|
+
// CLASS distinct from `consumeAssignment`'s own, already-reviewed
|
|
341
|
+
// precision limits (the "master false positive" class already pinned
|
|
342
|
+
// elsewhere in the test file) was found. See `normalizeCommandQuoteAware`'s
|
|
343
|
+
// own doc comment for the full mechanism and reasoning. CLOSED (task
|
|
344
|
+
// f561e44c): `src/cli/dry-run.ts`'s independently-copied matcher was
|
|
345
|
+
// initially not extended to try this fourth form — named residual — but
|
|
346
|
+
// that follow-up task added the same fourth OR-arm there too, see that
|
|
347
|
+
// file's own comment.
|
|
231
348
|
// - A quoted assignment VALUE containing the literal word `git`/`gh`/
|
|
232
349
|
// `npm`/`harness` as its own token (`VAR='a git b' git push`): the
|
|
233
350
|
// continuation's one-directional guard abandons rather than risk
|
|
@@ -315,6 +432,28 @@
|
|
|
315
432
|
// trips the gate, the same cost the dbc6d303 quoted/heredoc class
|
|
316
433
|
// already carries.
|
|
317
434
|
//
|
|
435
|
+
// SAME CLASS, third member (task aabbad63, this run's own amp-aware
|
|
436
|
+
// second pass): a WIDENING of the second member above, not a new class —
|
|
437
|
+
// `AMP_BOUNDARY_RE` is quote-unaware in exactly the same way `BOUNDARY_RE`
|
|
438
|
+
// is, so a bare `&` INSIDE a quoted string is now also a segment
|
|
439
|
+
// boundary, and a wrapper spelling sitting behind it can be peeled and
|
|
440
|
+
// canonicalised as though it were a real invocation. Measured on a
|
|
441
|
+
// 160-form quoted-text corpus arbitrated by real bash (PATH-shimmed
|
|
442
|
+
// verbs + a marker file the verb would touch if it actually ran): 144
|
|
443
|
+
// spellings of `<text> & <wrapper> <gated verb>` INSIDE a quoted string
|
|
444
|
+
// now match where the base (pre-aabbad63) build did not, and bash ran
|
|
445
|
+
// the gated verb in 0 of them — a false alarm on a command that only
|
|
446
|
+
// PRINTS the spelling, never a missed real gate. The wrapper-LESS
|
|
447
|
+
// variant of the same shape (`echo 'a & git push'`) already false-
|
|
448
|
+
// alarmed on the base build 16/16 via the RAW regex alone, because every
|
|
449
|
+
// shipped `bash_match` trigger already anchors on a bare `&` (task
|
|
450
|
+
// `d834a065`) — so this is a WIDENING of an already-accepted over-block
|
|
451
|
+
// class to the wrapped spellings of it, not a new false-positive class
|
|
452
|
+
// this task introduces. The opposite direction, measured on the general
|
|
453
|
+
// (non-adversarial) corpus rather than this quoted-text one: of 276
|
|
454
|
+
// matches this pass gains there, all 151 classifiable ones were TRUE
|
|
455
|
+
// positives, 0 false alarms.
|
|
456
|
+
//
|
|
318
457
|
// `$(...)` command substitution is ACCIDENTALLY covered — not a
|
|
319
458
|
// deliberate feature, and distinct from the backtick case above, which
|
|
320
459
|
// is a genuine, deliberate gap. `BOUNDARY_RE` treats a bare `(` as a
|
|
@@ -407,26 +546,263 @@ export const MAX_NORMALIZE_LENGTH = 100_000;
|
|
|
407
546
|
* starts with `&`), so there is no tie for the regex engine's leftmost-
|
|
408
547
|
* match rule to break.
|
|
409
548
|
*
|
|
410
|
-
* DELIBERATELY MISSING: single `&`, even though bash starts a new
|
|
411
|
-
* command after it
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
* the value continuation never engages and the wrapper is never peeled.
|
|
549
|
+
* DELIBERATELY MISSING, STILL: single `&`, even though bash starts a new
|
|
550
|
+
* command after it. Adding it HERE was tried and REVERTED after
|
|
551
|
+
* measurement: this regex is quote-unaware, `&` inside a quoted
|
|
552
|
+
* assignment value is common, and the split lands mid-quote, so the
|
|
553
|
+
* value continuation never engages and the wrapper is never peeled.
|
|
416
554
|
* Measured against the pre-change build with a passing positive
|
|
417
555
|
* control: 140 of 140 sampled `<wrapper> FOO='a&b' <gated verb>`
|
|
418
556
|
* spellings LOST a gate they previously held, including the
|
|
419
|
-
* `operator_only` kill-switch deny.
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
557
|
+
* `operator_only` kill-switch deny. That evidence is why THIS alphabet
|
|
558
|
+
* stays byte-identical rather than growing a bare `&` alternative
|
|
559
|
+
* directly — see `scripts/measure-command-normalize.mjs`'s arm A for the
|
|
560
|
+
* regression corpus that keeps proving it stays byte-identical.
|
|
561
|
+
*
|
|
562
|
+
* TASK aabbad63 (this run): the bare-`&` family this alphabet cannot see
|
|
563
|
+
* (`A=x&env -C /tmp git status`, `echo hi & nice git status`) is now
|
|
564
|
+
* handled WITHOUT touching this regex at all — by a SECOND, independent
|
|
565
|
+
* boundary alphabet (`AMP_BOUNDARY_RE` below) driving a SECOND
|
|
566
|
+
* normalisation pass (`normalizeCommandAmpAware`) that
|
|
567
|
+
* `policyMatchesEvent` (`src/runtime/intercept.ts`) tries ONLY as a
|
|
568
|
+
* third, additional raw-OR-normalised-OR-amp-normalised disjunct — never
|
|
569
|
+
* a replacement for either of the first two arms. That construction is
|
|
570
|
+
* exactly what avoids re-triggering the 140/140 regression above: every
|
|
571
|
+
* command that matched via the raw test or via THIS (BOUNDARY_RE)
|
|
572
|
+
* normalised form keeps matching through those same two arms, byte-for-
|
|
573
|
+
* byte unchanged; the amp-aware arm can only ADD a match a spelling
|
|
574
|
+
* would otherwise have missed, so a quoted `FOO='a&b'` value that this
|
|
575
|
+
* alphabet already normalises correctly is never at risk of losing that
|
|
576
|
+
* gate to the new arm. See `AMP_BOUNDARY_RE`'s own comment for why it
|
|
577
|
+
* has to be a distinct regex object rather than a mutation of this one,
|
|
578
|
+
* and `normalizeCommandAmpAware`'s comment for why its return type
|
|
579
|
+
* carries no `targetDir`/`targetBase`.
|
|
424
580
|
*/
|
|
425
581
|
const BOUNDARY_RE = /\n|&&|;|\||\(/g;
|
|
582
|
+
/**
|
|
583
|
+
* Second boundary alphabet (task aabbad63): identical to `BOUNDARY_RE`
|
|
584
|
+
* plus a bare `&` — bash's OWN "start a new command here" token, which
|
|
585
|
+
* every shipped `bash_match` trigger regex has treated as one of its own
|
|
586
|
+
* anchor characters since task `d834a065`, but which `BOUNDARY_RE` above
|
|
587
|
+
* has never recognised as a NORMALISATION-time boundary (see its comment
|
|
588
|
+
* for the reverted, measured-140/140-regression attempt to add it there
|
|
589
|
+
* directly).
|
|
590
|
+
*
|
|
591
|
+
* ALTERNATION ORDER IS LOAD-BEARING: `&&` MUST be listed to the LEFT of
|
|
592
|
+
* the bare `&` alternative. JS regex alternation is leftmost-FIRST, not
|
|
593
|
+
* leftmost-longest — with `&` tried before `&&`, every `&&` in a command
|
|
594
|
+
* would tokenise as TWO consecutive one-character `&` boundary MATCHES
|
|
595
|
+
* instead of a single two-character `&&` boundary match.
|
|
596
|
+
*
|
|
597
|
+
* MEASURED, NOT ASSUMED (do not "simplify" the pin below to an
|
|
598
|
+
* end-to-end `.normalized` string assertion): for THIS module's segment-
|
|
599
|
+
* and-rejoin architecture specifically, that reordering is, perhaps
|
|
600
|
+
* counter-intuitively, INVISIBLE in `normalizeCommandAmpAware`'s own
|
|
601
|
+
* output string. Two adjacent single-`&` matches sandwich a genuinely
|
|
602
|
+
* zero-length segment between them (the two `&` characters of a real
|
|
603
|
+
* `&&` are, by definition, adjacent), `canonicalizeSegment("")` is a
|
|
604
|
+
* verbatim no-op, and `parts.join("")` reassembles the exact same bytes
|
|
605
|
+
* either way — verified directly (not merely reasoned about) by
|
|
606
|
+
* temporarily swapping this alternation's order, rebuilding, and
|
|
607
|
+
* diffing `normalizeCommandAmpAware(...).normalized` across several
|
|
608
|
+
* `&&`-bearing commands (including this comment's own `git -C
|
|
609
|
+
* /tmp/repoB status && git -C /tmp/repoB log` example): byte-identical
|
|
610
|
+
* output both ways. So a test asserting on the OUTPUT STRING alone
|
|
611
|
+
* cannot distinguish the two orderings for this architecture — the pin
|
|
612
|
+
* in `tests/runtime/command-normalize.test.ts` therefore execs this
|
|
613
|
+
* regex object DIRECTLY against a `&&`-bearing string and asserts the
|
|
614
|
+
* match text is `"&&"`, not `"&"`, which DOES fail under a swap. The
|
|
615
|
+
* order is still specified exactly as documented above (leftmost-first
|
|
616
|
+
* is real JS semantics, and the internal `segmentAndCanonicalize`
|
|
617
|
+
* bookkeeping this alphabet ALSO drives — `hasAmbiguousNonGitSegment`,
|
|
618
|
+
* discarded by `normalizeCommandAmpAware` but not by a hypothetical
|
|
619
|
+
* future consumer of `segmentAndCanonicalize` under this alphabet —
|
|
620
|
+
* would still see a spurious extra "non-git segment" under the wrong
|
|
621
|
+
* order), it just cannot be pinned through this module's ONE exposed
|
|
622
|
+
* string alone.
|
|
623
|
+
*
|
|
624
|
+
* Used ONLY by `normalizeCommandAmpAware` below, which is consulted ONLY
|
|
625
|
+
* as `policyMatchesEvent`'s third, additional matching arm — see that
|
|
626
|
+
* function's own comment for why its result never carries a `targetDir`/
|
|
627
|
+
* `targetBase`, and `src/runtime/intercept.ts` for the memoised-thunk
|
|
628
|
+
* threading that keeps this off the common (raw-or-normalised-already-
|
|
629
|
+
* matched) path. Exported (mirrors `GIT_TOKEN_RE` / `NON_GIT_HEAD_TOKENS`
|
|
630
|
+
* / `MAX_NORMALIZE_LENGTH` above) solely so the ordering pin test can
|
|
631
|
+
* exec it directly instead of re-declaring an equivalent regex literal
|
|
632
|
+
* that would test JS semantics in general, not THIS source's actual
|
|
633
|
+
* alternation.
|
|
634
|
+
*/
|
|
635
|
+
export const AMP_BOUNDARY_RE = /\n|&&|&|;|\||\(/g;
|
|
426
636
|
/** A target value starting with `~` — not expanded, treated as unparseable (F5). */
|
|
427
637
|
function isTildeTarget(dir) {
|
|
428
638
|
return dir.startsWith("~");
|
|
429
639
|
}
|
|
640
|
+
/**
|
|
641
|
+
* A target value `segmentViewOf`'s `CommandSegment.ownTarget` (task
|
|
642
|
+
* `98ad072f` groundwork) treats as unattributable even where the
|
|
643
|
+
* pre-existing per-segment machinery below (`canonicalizeSegment` /
|
|
644
|
+
* `peelGitGlobalOptions` / `peelEnv`) still captures SOME string for the
|
|
645
|
+
* OLD aggregate's `targetDir` — that extraction is UNCHANGED by this check
|
|
646
|
+
* (see the module header's STATUS/UPDATE note): the tokeniser is not
|
|
647
|
+
* quote-aware, so a quoted `-C` / `--work-tree` / `--git-dir` / `env -C`
|
|
648
|
+
* value is captured WITH its quote character(s) still attached
|
|
649
|
+
* (`git -C "/tmp/repoB" status` captures `"/tmp/repoB"`, quotes included —
|
|
650
|
+
* not a real path, and not `null` either under the pre-existing
|
|
651
|
+
* extraction) rather than being rejected outright. The same is true of a
|
|
652
|
+
* value containing a command-substitution marker (`$(` or a backtick) —
|
|
653
|
+
* this module never resolves substitutions, so a value carrying one names
|
|
654
|
+
* nothing this module can vouch for as a literal path. A
|
|
655
|
+
* whitespace-containing path needs no separate check here: the
|
|
656
|
+
* whitespace-splitting tokeniser never captures a value containing
|
|
657
|
+
* whitespace as ONE token in the first place — it becomes a different,
|
|
658
|
+
* unrecognised token sequence instead (see the module header's own
|
|
659
|
+
* "quoted directory arguments containing whitespace" note), which for
|
|
660
|
+
* `segmentViewOf` purposes simply fails to produce a recognised own target
|
|
661
|
+
* at all (see `parseCdSegmentTarget` below for the `cd` case).
|
|
662
|
+
*
|
|
663
|
+
* A value ENDING in a bare `$` is ALSO rejected, even though it does not
|
|
664
|
+
* literally contain the two-character `$(` marker: `BOUNDARY_RE` (this
|
|
665
|
+
* function is only ever reached through the primary, non-amp-aware pass —
|
|
666
|
+
* `segmentViewOf` never runs under `AMP_BOUNDARY_RE`) treats a bare `(` as
|
|
667
|
+
* a shell boundary UNCONDITIONALLY (needed for the leading-command case,
|
|
668
|
+
* module header), so `$(` can never survive as a substring WITHIN one
|
|
669
|
+
* token or segment in the first place — the `(` always splits it into its
|
|
670
|
+
* own boundary match first. A `cd` target ending in `$` is the tell-tale
|
|
671
|
+
* leftover of exactly that split (`cd $(pwd)/T && ...` tokenises this
|
|
672
|
+
* function's caller's OWN segment as literal `cd $`, a syntactically
|
|
673
|
+
* valid-LOOKING two-token `cd <path>` with `<path>` being the single
|
|
674
|
+
* character `$` — measured live, not hypothetical). Rejecting it here
|
|
675
|
+
* closes that gap without needing `parseCdSegmentTarget` (which has no
|
|
676
|
+
* visibility into what boundary token follows its own segment) to
|
|
677
|
+
* special-case it itself. The `-C`/`--work-tree`/`--git-dir`/`env -C` side
|
|
678
|
+
* of this module never has this specific gap: a value-capturing git
|
|
679
|
+
* global option additionally requires a SUBCOMMAND token after its value
|
|
680
|
+
* (`peelGitGlobalOptions`'s caller checks `gitOpts.idx >= tokens.length`),
|
|
681
|
+
* so `git -C $(pwd) status` already comes back `isGit: false` (no
|
|
682
|
+
* subcommand token survives the split) before this function is ever
|
|
683
|
+
* consulted for it — this extra check is `cd`-specific, `cd <path>` having
|
|
684
|
+
* no analogous "and then a subcommand" requirement.
|
|
685
|
+
*/
|
|
686
|
+
function isUnattributableTargetValue(value) {
|
|
687
|
+
return (value.includes("'") ||
|
|
688
|
+
value.includes('"') ||
|
|
689
|
+
value.includes("$(") ||
|
|
690
|
+
value.includes("`") ||
|
|
691
|
+
value.endsWith("$"));
|
|
692
|
+
}
|
|
693
|
+
function classifyCdSegment(segmentText) {
|
|
694
|
+
const tokens = tokenizeWithOffsets(segmentText);
|
|
695
|
+
if (tokens.length === 0)
|
|
696
|
+
return { kind: "none" };
|
|
697
|
+
const head = tokens[0].text;
|
|
698
|
+
if (head === "pushd" || head === "popd")
|
|
699
|
+
return { kind: "reset" };
|
|
700
|
+
if (head !== "cd")
|
|
701
|
+
return { kind: "none" };
|
|
702
|
+
if (tokens.length === 1)
|
|
703
|
+
return { kind: "reset" }; // bare `cd`, no argument
|
|
704
|
+
if (tokens.length > 2)
|
|
705
|
+
return { kind: "reset" }; // flagged cd: `cd -P /tmp/x`
|
|
706
|
+
const arg = tokens[1].text;
|
|
707
|
+
if (arg.length === 0 || arg.startsWith("-"))
|
|
708
|
+
return { kind: "reset" }; // `cd -`, `cd -P` alone
|
|
709
|
+
return { kind: "cd", arg };
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Compute one segment's `ownTarget` / `effectiveTarget` (task `98ad072f`
|
|
713
|
+
* groundwork, `segmentViewOf` / `CommandSegment`) and the cd-basis to
|
|
714
|
+
* carry into the NEXT segment, from: this segment's own canonicalisation
|
|
715
|
+
* result (already computed by the shared walk — `identityTargetDir` /
|
|
716
|
+
* `identityTargetBase` / `isGit`, unmodified here; NOT the OLD aggregate's
|
|
717
|
+
* `targetDir`/`targetBase`, which still includes `--work-tree` — D-017,
|
|
718
|
+
* fix round 2), whether THIS segment is itself a recognised `cd <path>`
|
|
719
|
+
* segment, and the cd-basis carried in from whatever preceded it.
|
|
720
|
+
* Implements `CommandSegment.effectiveTarget`'s doc comment verbatim; see
|
|
721
|
+
* that comment for the consumer-facing statement of these rules.
|
|
722
|
+
*/
|
|
723
|
+
function computeSegmentTarget(segmentText, canon, incomingCdBasis) {
|
|
724
|
+
// Same-invocation `env -C`/`--chdir` base + a RELATIVE own git
|
|
725
|
+
// REPO-RELOCATING target (`-C`/`--git-dir` — orchestrator follow-up
|
|
726
|
+
// after the initial T-002 round, still task `98ad072f` groundwork):
|
|
727
|
+
// `canon.identityTargetBase` is non-null EXACTLY in this mixed case —
|
|
728
|
+
// see `canonicalizeSegment`'s own `identityTargetBase` computation,
|
|
729
|
+
// reused here unmodified, never when there is no wrapping `env -C` on
|
|
730
|
+
// the SAME invocation. `env -C /tmp/base git -C sub status` must NOT
|
|
731
|
+
// hand a future consumer `ownTarget`/`effectiveTarget: "sub"` (raw,
|
|
732
|
+
// relative): a naive consumer resolving a relative value against ITS
|
|
733
|
+
// OWN cwd (this module's documented fallback for the "no known basis"
|
|
734
|
+
// branch below) would land on a CONFIDENTLY WRONG repository whenever
|
|
735
|
+
// the caller's cwd differs from `/tmp/base` — precisely the "relative
|
|
736
|
+
// and `~` target dirs resolve to a confidently wrong repo" class named
|
|
737
|
+
// in the 07-27 review. Composing correctly to `/tmp/base/sub` would ALSO
|
|
738
|
+
// be correct but is deliberately NOT this module's job (real
|
|
739
|
+
// filesystem-shaped path joining — the same reason the cd-basis
|
|
740
|
+
// composition rule below refuses to compose `cd T && git -C sub` into
|
|
741
|
+
// `T/sub`); noted here as a possible LATER precision gain, not built in
|
|
742
|
+
// this slice. A bare git invocation with an `env -C` base and no
|
|
743
|
+
// relative own REPO-RELOCATING target (`env -C /abs git status`) is
|
|
744
|
+
// UNAFFECTED — the ordinary `canon.identityTargetDir` extraction below
|
|
745
|
+
// already folds that in (`gitOpts.relocateTargetDir ?? envTargetDir`),
|
|
746
|
+
// and `canon.identityTargetBase` stays `null` for that shape (no
|
|
747
|
+
// relative own repo-relocating target to be relative TO). A `--work-
|
|
748
|
+
// tree`-only invocation (no `-C`/`--git-dir` on the same call) is NEVER
|
|
749
|
+
// "mixed" here even when wrapped by `env -C`: `relocateTargetDir` is
|
|
750
|
+
// `null` for it, so `identityTargetDir` falls through to `envTargetDir`
|
|
751
|
+
// (or stays `null`) and `identityTargetBase` stays `null` — D-017, this
|
|
752
|
+
// is the fix. A `cd`-shaped segment never reaches this branch: it never
|
|
753
|
+
// matches `GIT_TOKEN_RE`, so `canon.isGit` is always `false` for one.
|
|
754
|
+
if (canon.isGit && canon.identityTargetBase !== null) {
|
|
755
|
+
return { ownTarget: null, effectiveTarget: null, outgoingCdBasis: incomingCdBasis };
|
|
756
|
+
}
|
|
757
|
+
const cdClass = classifyCdSegment(segmentText);
|
|
758
|
+
// D-014 (fix round, run 2026-08-02-per-repo-gate-scoping-redesign): a
|
|
759
|
+
// segment that GENUINELY changes directory to somewhere this module
|
|
760
|
+
// cannot name (bare `cd`, `cd -`, a flagged `cd`, `pushd`, `popd`)
|
|
761
|
+
// RESETS the running basis to `null` for every later segment, rather
|
|
762
|
+
// than the pre-fix behaviour of leaving it unchanged (silently
|
|
763
|
+
// INHERITING whatever preceded it, as though the shell had not moved at
|
|
764
|
+
// all). This segment names nothing itself either. A PRECISION fix, not
|
|
765
|
+
// the security fix: `src/runtime/intercept.ts`'s D-011 additive
|
|
766
|
+
// attribution rule means an inherited target always also demands the
|
|
767
|
+
// cwd context, so an over-cautious reset here can only make a LATER
|
|
768
|
+
// segment fall back to cwd-only evaluation — identical to the shipped
|
|
769
|
+
// baseline for an unattributable form (D-003), never a fail-open.
|
|
770
|
+
if (cdClass.kind === "reset") {
|
|
771
|
+
return { ownTarget: null, effectiveTarget: null, outgoingCdBasis: null };
|
|
772
|
+
}
|
|
773
|
+
const cdArg = cdClass.kind === "cd" ? cdClass.arg : null;
|
|
774
|
+
const rawOwn = cdArg !== null ? cdArg : canon.isGit ? canon.identityTargetDir : null;
|
|
775
|
+
const ownTarget = rawOwn !== null && !isTildeTarget(rawOwn) && !isUnattributableTargetValue(rawOwn)
|
|
776
|
+
? rawOwn
|
|
777
|
+
: null;
|
|
778
|
+
let effectiveTarget;
|
|
779
|
+
if (ownTarget === null) {
|
|
780
|
+
// D-014: a recognised `cd <path>` segment whose OWN value came out
|
|
781
|
+
// unattributable (tilde/quoted/substitution) RESETS rather than
|
|
782
|
+
// inherits — the shell genuinely changed directory to somewhere this
|
|
783
|
+
// module cannot name, so silently keeping an earlier basis in place
|
|
784
|
+
// would be a confidently-wrong carry-forward, exactly the class this
|
|
785
|
+
// whole module exists to avoid. A segment that is NOT cd-shaped at
|
|
786
|
+
// all (`cdArg === null`, `cdClass.kind === "none"`) still inherits —
|
|
787
|
+
// this module has no evidence the shell moved, so it does not guess.
|
|
788
|
+
effectiveTarget = cdArg !== null ? null : incomingCdBasis;
|
|
789
|
+
}
|
|
790
|
+
else if (path.isAbsolute(ownTarget)) {
|
|
791
|
+
effectiveTarget = ownTarget;
|
|
792
|
+
}
|
|
793
|
+
else {
|
|
794
|
+
effectiveTarget = incomingCdBasis === null ? ownTarget : null;
|
|
795
|
+
}
|
|
796
|
+
// Only a recognised `cd <path>` segment updates the outgoing basis
|
|
797
|
+
// (whether or not its OWN value came out attributable — see
|
|
798
|
+
// `CommandSegment.effectiveTarget`'s doc comment for why an
|
|
799
|
+
// unattributable `cd` must still RESET the basis to `null`, not leave a
|
|
800
|
+
// stale earlier one in place). Every other segment — gated or not —
|
|
801
|
+
// never itself changes the shell's directory, so the incoming basis
|
|
802
|
+
// passes through unchanged.
|
|
803
|
+
const outgoingCdBasis = cdArg !== null ? effectiveTarget : incomingCdBasis;
|
|
804
|
+
return { ownTarget, effectiveTarget, outgoingCdBasis };
|
|
805
|
+
}
|
|
430
806
|
/**
|
|
431
807
|
* Given a Bash command string, return its normalised form and the target
|
|
432
808
|
* directory (and base, if relative) of its git invocation. Never throws:
|
|
@@ -454,7 +830,269 @@ export function normalizeCommand(command) {
|
|
|
454
830
|
return { normalized: command, targetDir: null, targetBase: null, truncated: false };
|
|
455
831
|
}
|
|
456
832
|
}
|
|
833
|
+
/**
|
|
834
|
+
* Given a Bash command string, return its per-segment view (task
|
|
835
|
+
* `98ad072f` groundwork, T-002) — see `CommandSegment`'s own doc comment
|
|
836
|
+
* for the field-level composition rules. `null` when the input exceeded
|
|
837
|
+
* `MAX_NORMALIZE_LENGTH` (mirrors `NormalizedCommand.truncated`, but as
|
|
838
|
+
* the ABSENCE of a view rather than a boolean flag alongside one, since
|
|
839
|
+
* there is no meaningful per-segment view to return for an input whose
|
|
840
|
+
* normalisation itself was skipped). `[]` for an empty or non-string
|
|
841
|
+
* command, and as the never-throws fallback (mirrors `normalizeCommand`'s
|
|
842
|
+
* own defensive backstop — see its doc comment above).
|
|
843
|
+
*
|
|
844
|
+
* THIS function's OWN call into `segmentAndCanonicalize` (`collectSegments:
|
|
845
|
+
* true`) is one walk, O(length) — the "no additional pass" claim is scoped
|
|
846
|
+
* to that ONE call, not to a caller's total work. CORRECTED (D-015, fix
|
|
847
|
+
* round, run 2026-08-02-per-repo-gate-scoping-redesign) — the prior wording
|
|
848
|
+
* here read "no additional pass over the command string" without that
|
|
849
|
+
* scoping, which a reader could take as "calling this costs nothing beyond
|
|
850
|
+
* `normalizeCommand`'s own walk." Measured false: `normalizeCommand(cmd)`
|
|
851
|
+
* and `segmentViewOf(cmd)` are two SEPARATE top-level calls, each running
|
|
852
|
+
* its own full `segmentAndCanonicalize` pass over the SAME string — a
|
|
853
|
+
* caller needing both (as `src/cli/policy/intercept.ts` does, for
|
|
854
|
+
* `normalized`/`truncated` and the segment view respectively) pays TWO
|
|
855
|
+
* walks, not one; measured +206% at the `MAX_NORMALIZE_LENGTH` bound
|
|
856
|
+
* (2.73 → 8.36 ms), harmless against the 1000 ms hook budget but not free.
|
|
857
|
+
* As of the same fix round, the one production caller only pays this
|
|
858
|
+
* second walk lazily, when some matching policy actually needs it (see
|
|
859
|
+
* `InterceptOptions.commandSegmentsThunk` in `src/runtime/intercept.ts`) —
|
|
860
|
+
* but that laziness lives in the CALLER, not in this function, which still
|
|
861
|
+
* does exactly one walk every time it runs. The ampersand-aware second pass
|
|
862
|
+
* (`normalizeCommandAmpAware`) NEVER collects a segment view (always calls
|
|
863
|
+
* `segmentAndCanonicalize` with `collectSegments: false`) — see that
|
|
864
|
+
* function's own comment for why a directory extracted under the
|
|
865
|
+
* ampersand-aware alphabet would be unreliable in the first place; the
|
|
866
|
+
* same reasoning applies here, so this module offers no amp-aware variant
|
|
867
|
+
* of this function at all.
|
|
868
|
+
*/
|
|
869
|
+
export function segmentViewOf(command) {
|
|
870
|
+
if (typeof command !== "string" || command.length === 0)
|
|
871
|
+
return [];
|
|
872
|
+
if (command.length > MAX_NORMALIZE_LENGTH)
|
|
873
|
+
return null;
|
|
874
|
+
try {
|
|
875
|
+
return segmentAndCanonicalize(command, BOUNDARY_RE, true).segments ?? [];
|
|
876
|
+
}
|
|
877
|
+
catch {
|
|
878
|
+
return [];
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Ampersand-aware SECOND normalisation pass (task aabbad63). Segments the
|
|
883
|
+
* command under `AMP_BOUNDARY_RE` (`BOUNDARY_RE` plus a bare `&`) instead
|
|
884
|
+
* of `BOUNDARY_RE`, then canonicalises exactly like the primary pass
|
|
885
|
+
* (same `canonicalizeSegment`, same wrapper-peeling rules, same
|
|
886
|
+
* `MAX_NORMALIZE_LENGTH` bound). Closes the gap `BOUNDARY_RE`'s own
|
|
887
|
+
* comment names: `A=x&env -C /tmp git status` and `echo hi & nice git
|
|
888
|
+
* status` were ungated because a wrapper can only be peeled after a
|
|
889
|
+
* recognised boundary, and a bare `&` was not one.
|
|
890
|
+
*
|
|
891
|
+
* Return type deliberately has NO `targetDir`/`targetBase` — not merely
|
|
892
|
+
* "always null under this alphabet", genuinely ABSENT from the type, so
|
|
893
|
+
* no consumer can read one by mistake. Reason: a bare `&` is ALSO how
|
|
894
|
+
* bash spells a background job (`git -C /x push &`) and how a redirect
|
|
895
|
+
* merges streams (`git -C /x log 2>&1`, `... &> out`). Under THIS
|
|
896
|
+
* alphabet each of those splits into a spurious extra "segment", which
|
|
897
|
+
* `segmentAndCanonicalize`'s own non-git-segment ambiguity rule (a
|
|
898
|
+
* segment that starts a new command and is not itself a git invocation
|
|
899
|
+
* forces `targetDir` to `null` — see its G1 comment) would turn into a
|
|
900
|
+
* wrong-shaped loss for a case the PRIMARY pass still resolves correctly
|
|
901
|
+
* today. Pinned in `tests/runtime/command-normalize.test.ts`'s "aabbad63
|
|
902
|
+
* groundwork" describe block, which exercises ONLY `normalizeCommand`
|
|
903
|
+
* (the primary pass), never this function — `targetDir`/`targetBase`
|
|
904
|
+
* stay the primary pass's property alone.
|
|
905
|
+
*
|
|
906
|
+
* Only consulted by `policyMatchesEvent`'s third arm
|
|
907
|
+
* (`src/runtime/intercept.ts`), and only when a policy's `bash_match`
|
|
908
|
+
* regex has already missed BOTH the raw command and the primary
|
|
909
|
+
* normalised form — see that module and `src/cli/policy/intercept.ts`
|
|
910
|
+
* for the memoised-thunk threading that keeps this computation off the
|
|
911
|
+
* common (already-matched) path.
|
|
912
|
+
*/
|
|
913
|
+
export function normalizeCommandAmpAware(command) {
|
|
914
|
+
return normalizeWithGuards(command, (c) => segmentAndCanonicalize(c, AMP_BOUNDARY_RE, false).normalized);
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Shared empty/length/throw guard clauses for a normalisation pass whose
|
|
918
|
+
* public return shape is exactly `{ normalized, truncated }` — no
|
|
919
|
+
* `targetDir`/`targetBase` (task cf3dff51 extraction: before this,
|
|
920
|
+
* `normalizeCommandAmpAware` and `normalizeCommandQuoteAware` carried
|
|
921
|
+
* byte-identical copies of this wrapper, which `npm run check:duplication`
|
|
922
|
+
* — a jscpd clone-count fitness function, see that script's own header
|
|
923
|
+
* comment for the precedent this follows — flagged as new duplication
|
|
924
|
+
* against the pinned baseline; extracted rather than raising the pin,
|
|
925
|
+
* per that script's own stated preference). `normalizeCommand` itself is
|
|
926
|
+
* NOT this shape (its return type also carries `targetDir`/`targetBase`)
|
|
927
|
+
* and keeps its own, separate guard clauses — deliberately not folded in
|
|
928
|
+
* here, since unifying THREE different return shapes behind one generic
|
|
929
|
+
* helper would obscure more than the ~10 duplicated lines it would save.
|
|
930
|
+
* `compute` is called only once `command` has passed both guards, so it
|
|
931
|
+
* never needs to re-check emptiness or the length bound itself.
|
|
932
|
+
*/
|
|
933
|
+
function normalizeWithGuards(command, compute) {
|
|
934
|
+
if (typeof command !== "string" || command.length === 0) {
|
|
935
|
+
return { normalized: typeof command === "string" ? command : "", truncated: false };
|
|
936
|
+
}
|
|
937
|
+
if (command.length > MAX_NORMALIZE_LENGTH) {
|
|
938
|
+
return { normalized: command, truncated: true };
|
|
939
|
+
}
|
|
940
|
+
try {
|
|
941
|
+
return { normalized: compute(command), truncated: false };
|
|
942
|
+
}
|
|
943
|
+
catch {
|
|
944
|
+
return { normalized: command, truncated: false };
|
|
945
|
+
}
|
|
946
|
+
}
|
|
457
947
|
function normalizeCommandInner(command) {
|
|
948
|
+
const { normalized, targetDir, targetBase } = segmentAndCanonicalize(command, BOUNDARY_RE, false);
|
|
949
|
+
return { normalized, targetDir, targetBase, truncated: false };
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* Quote-aware THIRD normalisation pass (task cf3dff51). Segments the
|
|
953
|
+
* command under `BOUNDARY_RE`'s own alphabet — same characters as the
|
|
954
|
+
* primary pass, no widening — but tracks single/double-quote state while
|
|
955
|
+
* scanning (`findNextBoundaryQuoteAware` below) so a boundary character
|
|
956
|
+
* sitting INSIDE a quoted assignment value is no longer treated as a real
|
|
957
|
+
* segment split. Closes the gap the module header's "DELIBERATELY NOT
|
|
958
|
+
* SUPPORTED" list and `BOUNDARY_RE`'s own comment both name: `VAR='a; b'
|
|
959
|
+
* git push` (task 13e55484's pinned residual) — `BOUNDARY_RE` used to cut
|
|
960
|
+
* the segment at the `;`, landing squarely between the two halves of one
|
|
961
|
+
* quoted value, so neither resulting piece carried a recognisable
|
|
962
|
+
* invocation and `consumeAssignment`'s own multi-token quote continuation
|
|
963
|
+
* (built for exactly this shape) never got the chance to run: it operates
|
|
964
|
+
* AFTER segmentation, on tokens WITHIN one segment, and the quoted value
|
|
965
|
+
* was never in one segment to begin with. Once quote-aware segmentation
|
|
966
|
+
* keeps the value whole, `consumeAssignment` needs no changes at all — it
|
|
967
|
+
* already reconstructs a multi-token quoted value correctly; this pass
|
|
968
|
+
* only fixes what gets handed to it as ONE segment in the first place.
|
|
969
|
+
*
|
|
970
|
+
* BUILT AS A NEW, INDEPENDENT PASS RATHER THAN AN IN-PLACE EDIT OF
|
|
971
|
+
* `BOUNDARY_RE` / `segmentAndCanonicalize` — this is the module header's
|
|
972
|
+
* HALT CRITERION, restated here at the point it governs: quote-aware
|
|
973
|
+
* segmentation can MERGE a segment the primary pass currently splits, and
|
|
974
|
+
* that merge cuts in BOTH directions — it can turn a previously-missed
|
|
975
|
+
* bypass into a match (the fix), but it can just as easily turn a
|
|
976
|
+
* previously-accepted, ALREADY-WIRED over-block into a miss (a
|
|
977
|
+
* regression). Measured, not hypothetical: `tests/runtime/command-
|
|
978
|
+
* normalize.test.ts`'s "accepted over-block: a quoted assignment inside
|
|
979
|
+
* TEXT after a boundary char now matches" pin — `echo "a; VAR='x y' git
|
|
980
|
+
* push"` — matches via `normalizeCommand` TODAY only because `BOUNDARY_RE`
|
|
981
|
+
* splits it at the `;`, landing `VAR='x y' git push` at a segment start
|
|
982
|
+
* where the assignment continuation turns it into a match. An in-place
|
|
983
|
+
* quote-aware edit of THAT pass would un-split it back into the outer
|
|
984
|
+
* `echo "..."` segment — not itself a recognised invocation — and flip
|
|
985
|
+
* that pin to a lost match, in violation of the raw-OR-normalised-OR-amp-
|
|
986
|
+
* aware-OR-(this) never-lose-a-match discipline every other pass in this
|
|
987
|
+
* module already honours. Building this as a SEPARATE pass instead means
|
|
988
|
+
* `normalizeCommand`'s own segmentation, and every test resting on it —
|
|
989
|
+
* including that exact one — is untouched by this task; see this file's
|
|
990
|
+
* own test suite for the differential proof (every case the first three
|
|
991
|
+
* arms already matched still matches, by construction, because none of
|
|
992
|
+
* those three arms' code changed at all).
|
|
993
|
+
*
|
|
994
|
+
* WIRED to `policyMatchesEvent` (`src/runtime/intercept.ts`) as its
|
|
995
|
+
* FOURTH arm — same shape as `normalizeCommandAmpAware`'s own third-arm
|
|
996
|
+
* wiring (task aabbad63): consulted only after the raw command,
|
|
997
|
+
* `normalizedCommand`, and the amp-aware pass have all missed, via a
|
|
998
|
+
* memoised thunk (`InterceptOptions.quoteNormalizedCommandThunk`,
|
|
999
|
+
* `src/cli/policy/intercept.ts`'s `quoteNormalizedCommandThunk`) so the
|
|
1000
|
+
* segmentation cost is paid at most once per event, on the common
|
|
1001
|
+
* (already-matched) path it costs nothing at all. Wiring landed in a
|
|
1002
|
+
* SECOND round of this task, after the orchestrator extended the first
|
|
1003
|
+
* round's file scope specifically for this one wiring point — the first
|
|
1004
|
+
* round shipped this function fully built and tested but genuinely
|
|
1005
|
+
* unwired (an inert security fix, called out explicitly as the wrong
|
|
1006
|
+
* shipped state in that round's own report) — `src/cli/dry-run.ts`'s own,
|
|
1007
|
+
* independently-copied matcher was deliberately NOT extended in the same
|
|
1008
|
+
* round (out of the extended scope too) and stayed a named residual until
|
|
1009
|
+
* task f561e44c closed it by adding the same fourth OR-arm there too — see
|
|
1010
|
+
* that file's own comment.
|
|
1011
|
+
*
|
|
1012
|
+
* THIS PASS'S OWN OVER-BLOCK SURFACE is measured and named in the test
|
|
1013
|
+
* file rather than assumed — and the measurement CORRECTS an earlier,
|
|
1014
|
+
* pre-measurement guess that used to sit in this comment: it does NOT
|
|
1015
|
+
* reproduce the `echo "a; VAR='x y' git push"` accepted over-block named
|
|
1016
|
+
* above. Quote-aware merging keeps that whole `echo "..."` segment
|
|
1017
|
+
* together, starting with the unrecognised head token `echo`, so this
|
|
1018
|
+
* function's output for it is byte-identical to the raw input
|
|
1019
|
+
* (`normalizeCommand`'s OWN, separate, unchanged-by-this-task match for
|
|
1020
|
+
* that same string is unaffected either way). Measured against a broader
|
|
1021
|
+
* adversarial probe, both at this function's own level and again at
|
|
1022
|
+
* `policyMatchesEvent`'s wired level (a bash-shim-verified corpus of
|
|
1023
|
+
* chained/prefixed/subshell-wrapped genuine invocations plus candidate
|
|
1024
|
+
* over-block shapes — entirely quoted `echo`/`printf` arguments that only
|
|
1025
|
+
* PRINT the spelling): every additional match this pass adds beyond raw /
|
|
1026
|
+
* `normalizeCommand` / `normalizeCommandAmpAware` is a TRUE positive (a
|
|
1027
|
+
* real invocation bash would actually execute); no NEW over-block class
|
|
1028
|
+
* distinct from `consumeAssignment`'s own, already-reviewed precision
|
|
1029
|
+
* limits was found. This function shares `canonicalizeSegment` /
|
|
1030
|
+
* `consumeAssignment` completely unchanged with the other passes — only
|
|
1031
|
+
* the segmentation boundary decision differs — which is also the
|
|
1032
|
+
* structural reason a longer (merged) segment can only REVEAL a match
|
|
1033
|
+
* those functions would already accept from a shorter view, never
|
|
1034
|
+
* fabricate a new kind of one; see the module header's cf3dff51 UPDATE
|
|
1035
|
+
* paragraph for the fuller statement of this reasoning and its
|
|
1036
|
+
* "not a formal proof" caveat.
|
|
1037
|
+
*/
|
|
1038
|
+
export function normalizeCommandQuoteAware(command) {
|
|
1039
|
+
return normalizeWithGuards(command, (c) => segmentAndCanonicalize(c, BOUNDARY_RE, false, true).normalized);
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* The shared segmentation + canonicalisation engine behind BOTH
|
|
1043
|
+
* normalisation passes (task aabbad63). Parameterised on the boundary
|
|
1044
|
+
* alphabet (`boundaryRe`) instead of closing over a single module-level
|
|
1045
|
+
* regex, so the primary pass (`BOUNDARY_RE`) and the ampersand-aware
|
|
1046
|
+
* second pass (`AMP_BOUNDARY_RE`) never share regex scan state: each
|
|
1047
|
+
* call is handed its OWN regex object, and `findNextBoundary` resets
|
|
1048
|
+
* that object's own `lastIndex` before every use, so the two passes
|
|
1049
|
+
* (even if a future change nested one inside the other, which nothing
|
|
1050
|
+
* here does today) can never observe or clobber each other's position.
|
|
1051
|
+
* `targetDir`/`targetBase` ARE still computed here regardless of which
|
|
1052
|
+
* alphabet drove the segmentation — `normalizeCommandAmpAware` below
|
|
1053
|
+
* simply never returns them to its own caller; see that function's
|
|
1054
|
+
* comment for why exposing them would be wrong under the amp alphabet
|
|
1055
|
+
* specifically. Fix round 1, finding F10: under the amp alphabet
|
|
1056
|
+
* specifically, ALL of this function's target bookkeeping
|
|
1057
|
+
* (`explicitTargets`, `explicitTargetBase`, `bareGitSegmentCount`,
|
|
1058
|
+
* `leadingCd`'s full `parseBashPrefix` pass, and the whole G1 ambiguity
|
|
1059
|
+
* resolution below) is knowingly computed and then discarded by
|
|
1060
|
+
* `normalizeCommandAmpAware` ITSELF, which returns only
|
|
1061
|
+
* `{ normalized, truncated }` — its own caller never sees these fields at
|
|
1062
|
+
* all. This is the containment design's cost, not an oversight, so a
|
|
1063
|
+
* future reader should not "fix" it by exposing these fields to a
|
|
1064
|
+
* hypothetical amp-alphabet consumer.
|
|
1065
|
+
*
|
|
1066
|
+
* `collectSegments` (task `98ad072f` groundwork, T-002): when `true`, ALSO
|
|
1067
|
+
* builds the per-segment view `segmentViewOf` returns (`CommandSegment[]`)
|
|
1068
|
+
* inside this SAME walk — no SECOND walk INSIDE this one function call,
|
|
1069
|
+
* just a bounded amount of extra work per segment already being visited
|
|
1070
|
+
* (see `computeSegmentTarget`). Scoped claim only (D-015 correction, fix
|
|
1071
|
+
* round, run 2026-08-02-per-repo-gate-scoping-redesign): this says nothing
|
|
1072
|
+
* about a CALLER that invokes this function twice with different
|
|
1073
|
+
* `collectSegments` values on the same string — see `segmentViewOf`'s own
|
|
1074
|
+
* doc comment, corrected in the same fix round, for the measured cost of
|
|
1075
|
+
* exactly that. `normalizeCommandInner` and
|
|
1076
|
+
* `normalizeCommandAmpAware` both pass `false`: the OLD aggregate never
|
|
1077
|
+
* needed this, and the amp-aware pass deliberately never exposes a segment
|
|
1078
|
+
* view at all (see that function's own comment). `segments` is `null` on
|
|
1079
|
+
* the returned object when `collectSegments` is `false`, an array
|
|
1080
|
+
* (possibly empty) otherwise.
|
|
1081
|
+
*
|
|
1082
|
+
* `quoteAware` (task cf3dff51, default `false` — every PRE-EXISTING call
|
|
1083
|
+
* site is unchanged, positionally omitting this parameter): selects
|
|
1084
|
+
* `findNextBoundaryQuoteAware` instead of `findNextBoundary` as the one
|
|
1085
|
+
* thing this walk asks to find the next segment split. Everything else in
|
|
1086
|
+
* this function — `canonicalizeSegment`, the target-directory bookkeeping,
|
|
1087
|
+
* the `collectSegments` per-segment view — is IDENTICAL either way; only
|
|
1088
|
+
* WHERE a segment ends changes. Only `normalizeCommandQuoteAware` passes
|
|
1089
|
+
* `true`; see that function's own comment for why this had to be a new
|
|
1090
|
+
* parameter on the SHARED walk rather than a wholly separate function
|
|
1091
|
+
* (reuse, same reasoning `AMP_BOUNDARY_RE`'s parameterisation on
|
|
1092
|
+
* `boundaryRe` already established) and for the HALT CRITERION that ruled
|
|
1093
|
+
* out editing `findNextBoundary` itself in place instead.
|
|
1094
|
+
*/
|
|
1095
|
+
function segmentAndCanonicalize(command, boundaryRe, collectSegments, quoteAware = false) {
|
|
458
1096
|
const parts = [];
|
|
459
1097
|
const explicitTargets = new Set();
|
|
460
1098
|
let explicitTargetBase = null;
|
|
@@ -462,6 +1100,14 @@ function normalizeCommandInner(command) {
|
|
|
462
1100
|
let bareGitSegmentCount = 0;
|
|
463
1101
|
let i = 0;
|
|
464
1102
|
const n = command.length;
|
|
1103
|
+
// task `98ad072f` groundwork (T-002): the per-segment view's own,
|
|
1104
|
+
// independent bookkeeping — a running cd-basis (see
|
|
1105
|
+
// `computeSegmentTarget`) and the accumulated segments themselves.
|
|
1106
|
+
// Entirely separate from `explicitTargets` / `bareGitSegmentCount` /
|
|
1107
|
+
// `hasAmbiguousNonGitSegment` above, which stay exactly as they were:
|
|
1108
|
+
// this never reads or writes them, and they never read or write this.
|
|
1109
|
+
const segments = collectSegments ? [] : null;
|
|
1110
|
+
let cdBasis = null;
|
|
465
1111
|
// A leading `cd <dir> &&|;` prefix, parsed ONCE up front (G1 fix,
|
|
466
1112
|
// review round 2, 2026-07-27) instead of the two separate calls the
|
|
467
1113
|
// size===1 and size===0 branches below each used to make on their own.
|
|
@@ -486,11 +1132,54 @@ function normalizeCommandInner(command) {
|
|
|
486
1132
|
// least one boundary token's length each iteration a boundary is
|
|
487
1133
|
// found, and the loop stops once none remain.
|
|
488
1134
|
for (;;) {
|
|
489
|
-
const boundary =
|
|
1135
|
+
const boundary = quoteAware
|
|
1136
|
+
? findNextBoundaryQuoteAware(command, i, boundaryRe)
|
|
1137
|
+
: findNextBoundary(command, i, boundaryRe);
|
|
490
1138
|
const segEnd = boundary ? boundary.start : n;
|
|
491
1139
|
const segmentText = command.slice(i, segEnd);
|
|
492
1140
|
const result = canonicalizeSegment(segmentText);
|
|
493
1141
|
parts.push(result.text);
|
|
1142
|
+
if (segments !== null) {
|
|
1143
|
+
// task `98ad072f` groundwork (T-002): O(1) extra work per segment
|
|
1144
|
+
// already being visited — see `segmentAndCanonicalize`'s own doc
|
|
1145
|
+
// comment for why this is not a second pass over `command`.
|
|
1146
|
+
//
|
|
1147
|
+
// D-014 (fix round, run 2026-08-02-per-repo-gate-scoping-redesign),
|
|
1148
|
+
// two bash-truthfulness corrections applied around the call, not
|
|
1149
|
+
// inside `computeSegmentTarget` itself (both are about WHICH basis
|
|
1150
|
+
// crosses a BOUNDARY, not about one segment's own composition):
|
|
1151
|
+
//
|
|
1152
|
+
// - a running cd-basis never crosses a bare `|` — each side of a
|
|
1153
|
+
// pipeline is bash's own subshell, so a `cd` on one side cannot
|
|
1154
|
+
// affect the other (measured bypass: `cd <forged> | git push`
|
|
1155
|
+
// really runs `git push` at the real cwd, never `<forged>`).
|
|
1156
|
+
// The segment immediately after a `|` is handed `null` as its
|
|
1157
|
+
// incoming basis, as though nothing preceded it.
|
|
1158
|
+
// - the OUTGOING basis resets to `null` whenever THIS segment's
|
|
1159
|
+
// own text contains a `)` (a subshell close riding along after
|
|
1160
|
+
// `BOUNDARY_RE`'s bare `(` boundary — this alphabet has no `)`
|
|
1161
|
+
// boundary of its own, see its header comment — so a subshell's
|
|
1162
|
+
// `cd` genuinely stops applying once the subshell exits: `(cd
|
|
1163
|
+
// <forged> ; echo hi) && git push` must not let `<forged>` reach
|
|
1164
|
+
// `git push`). THIS segment's own `effectiveTarget` is computed
|
|
1165
|
+
// from the UNCHANGED incoming basis first — a segment ending in
|
|
1166
|
+
// `)` (e.g. the `git status)` tail of `(cd A && git status)`)
|
|
1167
|
+
// still correctly attributes to A for itself, exactly as it
|
|
1168
|
+
// should while still inside the subshell; only what carries
|
|
1169
|
+
// FORWARD to the next segment is affected. A simple substring
|
|
1170
|
+
// check, not paren-depth tracking: conservative, precision-only
|
|
1171
|
+
// under D-011 (see `computeSegmentTarget`'s own D-014 note) — an
|
|
1172
|
+
// over-eager reset here can only make a later segment fall back
|
|
1173
|
+
// to cwd-only, never a fail-open.
|
|
1174
|
+
const incomingBasis = precedingBoundaryToken === "|" ? null : cdBasis;
|
|
1175
|
+
const seg = computeSegmentTarget(segmentText, result, incomingBasis);
|
|
1176
|
+
segments.push({
|
|
1177
|
+
text: result.text,
|
|
1178
|
+
ownTarget: seg.ownTarget,
|
|
1179
|
+
effectiveTarget: seg.effectiveTarget,
|
|
1180
|
+
});
|
|
1181
|
+
cdBasis = segmentText.includes(")") ? null : seg.outgoingCdBasis;
|
|
1182
|
+
}
|
|
494
1183
|
if (result.isGit) {
|
|
495
1184
|
if (result.targetDir === null) {
|
|
496
1185
|
bareGitSegmentCount += 1;
|
|
@@ -585,14 +1274,251 @@ function normalizeCommandInner(command) {
|
|
|
585
1274
|
// push-gate fail-open this closes).
|
|
586
1275
|
targetDir = null;
|
|
587
1276
|
}
|
|
588
|
-
return { normalized: parts.join(""), targetDir, targetBase,
|
|
1277
|
+
return { normalized: parts.join(""), targetDir, targetBase, segments };
|
|
589
1278
|
}
|
|
590
|
-
/**
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
1279
|
+
/**
|
|
1280
|
+
* Find the earliest shell boundary token at or after `from`, under the
|
|
1281
|
+
* CALLER-SUPPLIED boundary alphabet. Takes `boundaryRe` as an explicit
|
|
1282
|
+
* parameter rather than closing over a single module-level regex (task
|
|
1283
|
+
* aabbad63) so the primary pass (`BOUNDARY_RE`) and the ampersand-aware
|
|
1284
|
+
* second pass (`AMP_BOUNDARY_RE`) each carry their own `lastIndex` state.
|
|
1285
|
+
*
|
|
1286
|
+
* Fix round 1, finding F8 (corrects the prior wording here): resetting
|
|
1287
|
+
* `lastIndex = from` on every call is NOT what stops the two passes from
|
|
1288
|
+
* clobbering each other's scan position — they are two DISTINCT regex
|
|
1289
|
+
* objects, so there is nothing shared for either to clobber, and
|
|
1290
|
+
* `segmentAndCanonicalize`'s own loop always calls this with `from`
|
|
1291
|
+
* already equal to where the previous match's `lastIndex` would have
|
|
1292
|
+
* landed anyway. That is why removing this reset measured 0 output
|
|
1293
|
+
* differences across the corpus and left the suite green (see
|
|
1294
|
+
* `tests/runtime/command-normalize.test.ts`'s F8 note — the mutation was
|
|
1295
|
+
* applied and reverted to confirm this, not merely reasoned about). The
|
|
1296
|
+
* line is still a legitimate guard, just for a DIFFERENT reason:
|
|
1297
|
+
* `BOUNDARY_RE` and `AMP_BOUNDARY_RE` are module-level `/g` objects
|
|
1298
|
+
* shared by every call in the process, so their `lastIndex` outlives any
|
|
1299
|
+
* single scan. If `canonicalizeSegment` THROWS partway through a scan,
|
|
1300
|
+
* the top-level `catch` in `normalizeCommand` /
|
|
1301
|
+
* `normalizeCommandAmpAware` returns a fallback but leaves that shared
|
|
1302
|
+
* object's `lastIndex` wherever the last successful match landed — and
|
|
1303
|
+
* the NEXT call, on a completely different command, would then start
|
|
1304
|
+
* scanning from that stale offset. This reset re-anchors it.
|
|
1305
|
+
*
|
|
1306
|
+
* DO NOT DELETE THIS LINE ON THE STRENGTH OF ITS MUTATION-INERTNESS.
|
|
1307
|
+
* Measured (fix round 2): with the reset removed and a throw forced into
|
|
1308
|
+
* `canonicalizeSegment`, the FOLLOWING call to
|
|
1309
|
+
* `normalizeCommandAmpAware("a | env git push origin master")` returned
|
|
1310
|
+
* the un-normalised string, and `preflight-before-push` went from MATCH
|
|
1311
|
+
* to NO-MATCH. That is a gate-losing fail-open, not a stylistic nit.
|
|
1312
|
+
* The honest caveat, stated rather than implied: no throw is KNOWN to be
|
|
1313
|
+
* reachable from real input — the leak above was demonstrated with a
|
|
1314
|
+
* forced throw, which is why the line measures inert. The module's own
|
|
1315
|
+
* top-level `catch` exists because a throw is treated as possible, and
|
|
1316
|
+
* this reset is the other half of that same posture.
|
|
1317
|
+
*/
|
|
1318
|
+
function findNextBoundary(s, from, boundaryRe) {
|
|
1319
|
+
boundaryRe.lastIndex = from;
|
|
1320
|
+
const m = boundaryRe.exec(s);
|
|
594
1321
|
return m === null ? null : { start: m.index, token: m[0] };
|
|
595
1322
|
}
|
|
1323
|
+
/**
|
|
1324
|
+
* Quote-aware sibling of `findNextBoundary` (task cf3dff51): the earliest
|
|
1325
|
+
* boundary token at or after `from` that sits OUTSIDE any open single- or
|
|
1326
|
+
* double-quoted run, under the SAME `boundaryRe` alphabet the caller
|
|
1327
|
+
* already scans with — no new boundary characters, only quote-tracking
|
|
1328
|
+
* around the existing ones. `null` when no such boundary exists (either
|
|
1329
|
+
* none remain at all, or every remaining candidate sits inside quoting
|
|
1330
|
+
* that never closes before the string ends — the same conservative "no
|
|
1331
|
+
* further split" fallback `consumeAssignment`'s own unterminated-quote
|
|
1332
|
+
* case already uses, restated here for whole-string segmentation instead
|
|
1333
|
+
* of one token).
|
|
1334
|
+
*
|
|
1335
|
+
* TWO-PHASE, NOT a per-character regex probe (task cf3dff51, latency fix
|
|
1336
|
+
* round — this replaces this function's first shipped implementation,
|
|
1337
|
+
* which called a sticky-regex `.exec()` at EVERY character position;
|
|
1338
|
+
* measured ~2x `normalizeCommand`'s own cost on a large, quote-free
|
|
1339
|
+
* command, because a JS-to-native regex-engine round trip at every single
|
|
1340
|
+
* character is far more expensive than the SAME regex engine's own
|
|
1341
|
+
* internal forward scan across many characters in one call — exactly what
|
|
1342
|
+
* `findNextBoundary` already does for the primary, non-quote-aware pass).
|
|
1343
|
+
* This version instead COMPOSES `findNextBoundary` (unchanged, still the
|
|
1344
|
+
* quote-UNAWARE fast forward scanner) with `QuoteScanner` (below): repeat
|
|
1345
|
+
* — find the next quote-unaware CANDIDATE boundary via `findNextBoundary`
|
|
1346
|
+
* (one native regex scan, however far away the candidate is), then walk
|
|
1347
|
+
* ONLY the span between the previous position and that candidate,
|
|
1348
|
+
* character-by-character, through `QuoteScanner` (below) to learn whether
|
|
1349
|
+
* a quote is open at the candidate's position; if not, the candidate IS
|
|
1350
|
+
* the real boundary — return it; if so, the candidate is inside a quote
|
|
1351
|
+
* (exactly the `VAR='a; b' git push` shape this pass exists to see
|
|
1352
|
+
* through), step through the candidate token's own characters too (never
|
|
1353
|
+
* quote-relevant — `BOUNDARY_RE`'s alphabet contains no `'`/`"`/`\` — so
|
|
1354
|
+
* this is a correctness no-op, only here so the running quote/escape
|
|
1355
|
+
* state stays byte-for-byte identical to a full character-by-character
|
|
1356
|
+
* walk) and continue the search from just past it. For a typical command
|
|
1357
|
+
* (few boundary characters, most of them outside any quote) this pays
|
|
1358
|
+
* for ONE `findNextBoundary` native scan per REAL segment plus one plain
|
|
1359
|
+
* (non-regex) character walk of the same total length `findNextBoundary`
|
|
1360
|
+
* would have scanned internally anyway — no longer one regex call per
|
|
1361
|
+
* character. `null` when no such boundary exists (either none remain at
|
|
1362
|
+
* all — `findNextBoundary` itself returns `null` — or every remaining
|
|
1363
|
+
* candidate sits inside quoting that never closes before the string ends,
|
|
1364
|
+
* the same conservative "no further split" fallback `consumeAssignment`'s
|
|
1365
|
+
* own unterminated-quote case already uses, restated here for whole-
|
|
1366
|
+
* string segmentation instead of one token).
|
|
1367
|
+
*
|
|
1368
|
+
* `QuoteScanner` (below) is the SAME quote/escape state machine
|
|
1369
|
+
* `consumeAssignment`'s own per-token loop uses (three states — `""`,
|
|
1370
|
+
* `'`, `"` — same POSIX approximation: outside quotes `\` escapes the
|
|
1371
|
+
* next char, `'...'` closes only at an UNESCAPED-by-construction `'`
|
|
1372
|
+
* (single quotes never honour backslash escapes at all), `"..."` closes
|
|
1373
|
+
* at an unescaped `"`). Extracted into a shared class (task cf3dff51 fix
|
|
1374
|
+
* round: `npm run check:duplication`'s jscpd scan flagged the two
|
|
1375
|
+
* independently-written copies of this loop as new duplication against
|
|
1376
|
+
* the pinned baseline; see that script's own header comment for why
|
|
1377
|
+
* extraction, not raising the pin, is the stated preference) rather than
|
|
1378
|
+
* duplicated a second time. Generalised from per-token to whole-string
|
|
1379
|
+
* scope by THIS function, not by the scanner itself: `consumeAssignment`
|
|
1380
|
+
* only ever sees one already-whitespace-tokenised segment at a time; this
|
|
1381
|
+
* function walks the RAW command text directly, whitespace included, so
|
|
1382
|
+
* it can tell whether a `;`/`|`/`&&`/`(`/`\n` character sits inside a
|
|
1383
|
+
* quote that a later whitespace-delimited token will re-enter (exactly
|
|
1384
|
+
* the shape `VAR='a; b' git push` needs: the space after `;` is itself
|
|
1385
|
+
* INSIDE the quoted run, not a token boundary this function cares about
|
|
1386
|
+
* at all — only the eventual UNQUOTED position matters).
|
|
1387
|
+
*
|
|
1388
|
+
* Reuses the MODULE-LEVEL `BOUNDARY_QUOTE_SCANNER` instance (`.reset()`
|
|
1389
|
+
* at the top of each call — see that constant's and `reset()`'s own
|
|
1390
|
+
* comments) instead of allocating a fresh `QuoteScanner` per call, and
|
|
1391
|
+
* calls `findNextBoundary` with the CALLER-SUPPLIED `boundaryRe` object
|
|
1392
|
+
* directly rather than a sticky clone — `findNextBoundary` already resets
|
|
1393
|
+
* `lastIndex` itself on every call (its own comment documents exactly
|
|
1394
|
+
* this discipline for the primary pass sharing the SAME `BOUNDARY_RE`
|
|
1395
|
+
* object), so composing it here needs no extra care. Called once per
|
|
1396
|
+
* segment (not once per character) by `segmentAndCanonicalize`'s loop;
|
|
1397
|
+
* the character walk itself still visits each character in the
|
|
1398
|
+
* `[from, end-of-match-or-string)` span exactly once IN TOTAL across all
|
|
1399
|
+
* calls for one command (split across the phase-1 "verify" loop and the
|
|
1400
|
+
* phase-2 "step through the false candidate" loop, never both for the
|
|
1401
|
+
* same character), keeping the whole walk O(length), same complexity
|
|
1402
|
+
* class `findNextBoundary`'s own comment establishes for the primary
|
|
1403
|
+
* pass — this rewrite changes the CONSTANT factor, not the asymptotic
|
|
1404
|
+
* class.
|
|
1405
|
+
*/
|
|
1406
|
+
function findNextBoundaryQuoteAware(s, from, boundaryRe) {
|
|
1407
|
+
BOUNDARY_QUOTE_SCANNER.reset();
|
|
1408
|
+
let pos = from;
|
|
1409
|
+
const n = s.length;
|
|
1410
|
+
for (;;) {
|
|
1411
|
+
const candidate = findNextBoundary(s, pos, boundaryRe);
|
|
1412
|
+
if (candidate === null)
|
|
1413
|
+
return null;
|
|
1414
|
+
// Phase 1: verify quote-safety by walking ONLY the span strictly
|
|
1415
|
+
// BEFORE the candidate — mirrors the original per-character scan,
|
|
1416
|
+
// which never steps through the boundary token's own characters
|
|
1417
|
+
// before deciding whether to return it.
|
|
1418
|
+
for (let i = pos; i < candidate.start; i++) {
|
|
1419
|
+
BOUNDARY_QUOTE_SCANNER.step(s[i]);
|
|
1420
|
+
}
|
|
1421
|
+
if (BOUNDARY_QUOTE_SCANNER.state === "") {
|
|
1422
|
+
return candidate;
|
|
1423
|
+
}
|
|
1424
|
+
// Phase 2: the candidate sits inside an open quote — not a real
|
|
1425
|
+
// boundary. Step through ITS OWN characters too (a correctness
|
|
1426
|
+
// no-op, see this function's own comment) so the running state stays
|
|
1427
|
+
// exactly what a full character-by-character walk would have
|
|
1428
|
+
// produced, then resume the search just past it.
|
|
1429
|
+
const tokenEnd = candidate.start + candidate.token.length;
|
|
1430
|
+
for (let i = candidate.start; i < tokenEnd; i++) {
|
|
1431
|
+
BOUNDARY_QUOTE_SCANNER.step(s[i]);
|
|
1432
|
+
}
|
|
1433
|
+
if (tokenEnd >= n)
|
|
1434
|
+
return null;
|
|
1435
|
+
pos = tokenEnd;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Mutable single-/double-quote + backslash-escape state machine (task
|
|
1440
|
+
* cf3dff51 extraction — see `findNextBoundaryQuoteAware`'s own comment for
|
|
1441
|
+
* why this was pulled out of two independently-written copies rather than
|
|
1442
|
+
* left duplicated). POSIX-approximated semantics, shared verbatim by both
|
|
1443
|
+
* consumers: outside quotes `\` escapes the next character; `'...'`
|
|
1444
|
+
* closes only at a literal `'` (single quotes never honour backslash
|
|
1445
|
+
* escapes — the `escaped` branch below is unreachable whenever
|
|
1446
|
+
* `state === "'"`, by construction: `step` checks `this.escaped` BEFORE
|
|
1447
|
+
* checking `this.state`, so an escape flagged one character earlier is
|
|
1448
|
+
* always consumed first, but nothing inside the `state === "'"` branch
|
|
1449
|
+
* itself ever SETS `escaped`, so it can never observe a `\` as anything
|
|
1450
|
+
* other than a literal character); `"..."` closes at an unescaped `"`. A
|
|
1451
|
+
* mutable class rather than a pure step function returning a new state
|
|
1452
|
+
* object: this module runs on every Bash/Edit/Write tool call (module
|
|
1453
|
+
* header) and both consumers step through EVERY character of a segment or
|
|
1454
|
+
* token, so one allocation per scan (not one per character) matters for
|
|
1455
|
+
* the same "allocation-light" reason the module header states elsewhere.
|
|
1456
|
+
*/
|
|
1457
|
+
class QuoteScanner {
|
|
1458
|
+
state = "";
|
|
1459
|
+
escaped = false;
|
|
1460
|
+
/** Advance the state machine by exactly one character. */
|
|
1461
|
+
step(c) {
|
|
1462
|
+
if (this.escaped) {
|
|
1463
|
+
this.escaped = false;
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
if (this.state === "'") {
|
|
1467
|
+
if (c === "'")
|
|
1468
|
+
this.state = "";
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
if (this.state === '"') {
|
|
1472
|
+
if (c === "\\")
|
|
1473
|
+
this.escaped = true;
|
|
1474
|
+
else if (c === '"')
|
|
1475
|
+
this.state = "";
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
// state === ""
|
|
1479
|
+
if (c === "\\")
|
|
1480
|
+
this.escaped = true;
|
|
1481
|
+
else if (c === "'" || c === '"')
|
|
1482
|
+
this.state = c;
|
|
1483
|
+
}
|
|
1484
|
+
/**
|
|
1485
|
+
* Reset to the initial `""` / not-escaped state — behaviourally
|
|
1486
|
+
* IDENTICAL to discarding this instance and constructing a fresh one
|
|
1487
|
+
* (task cf3dff51, latency fix round: lets `findNextBoundaryQuoteAware`
|
|
1488
|
+
* reuse ONE module-level instance across every segment of a command
|
|
1489
|
+
* instead of allocating a new one per segment — see that function's own
|
|
1490
|
+
* comment for the measured latency this closes).
|
|
1491
|
+
*/
|
|
1492
|
+
reset() {
|
|
1493
|
+
this.state = "";
|
|
1494
|
+
this.escaped = false;
|
|
1495
|
+
}
|
|
1496
|
+
/**
|
|
1497
|
+
* Clear a pending escape without consuming a character. Used only by
|
|
1498
|
+
* `consumeAssignment`, at a whitespace-tokenised TOKEN boundary: a
|
|
1499
|
+
* backslash pending at a token's end would otherwise escape the
|
|
1500
|
+
* separating whitespace itself (task b093911d's class, deliberately
|
|
1501
|
+
* NOT continued — see that function's own comment). `state` is never
|
|
1502
|
+
* touched by this — an open quote genuinely stays open across
|
|
1503
|
+
* whitespace, only a dangling escape does not.
|
|
1504
|
+
*/
|
|
1505
|
+
resetEscape() {
|
|
1506
|
+
this.escaped = false;
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
/**
|
|
1510
|
+
* Module-level `QuoteScanner` instance reused by `findNextBoundaryQuoteAware`
|
|
1511
|
+
* across every segment of every command (task cf3dff51, latency fix
|
|
1512
|
+
* round) — `.reset()` at the top of each call makes reusing this single
|
|
1513
|
+
* instance behaviourally IDENTICAL to `new QuoteScanner()` per call (see
|
|
1514
|
+
* `reset()`'s own doc comment), just without the allocation. Placed here,
|
|
1515
|
+
* textually AFTER `class QuoteScanner`, because a top-level `const`
|
|
1516
|
+
* initialiser referencing a class runs in real module-evaluation order —
|
|
1517
|
+
* `findNextBoundaryQuoteAware` itself sits ABOVE this point in the file
|
|
1518
|
+
* but is a hoisted function declaration whose BODY only runs when called,
|
|
1519
|
+
* by which time this line has already executed.
|
|
1520
|
+
*/
|
|
1521
|
+
const BOUNDARY_QUOTE_SCANNER = new QuoteScanner();
|
|
596
1522
|
/**
|
|
597
1523
|
* Consume one leading `VAR=value` assignment starting at `idx`, returning
|
|
598
1524
|
* the index of the first token AFTER it. Task 13e55484: the VALUE may be
|
|
@@ -653,40 +1579,19 @@ function findNextBoundary(s, from) {
|
|
|
653
1579
|
* pinned.
|
|
654
1580
|
*/
|
|
655
1581
|
function consumeAssignment(tokens, idx) {
|
|
656
|
-
|
|
657
|
-
let escaped = false;
|
|
1582
|
+
const quote = new QuoteScanner();
|
|
658
1583
|
for (let i = idx; i < tokens.length; i++) {
|
|
659
1584
|
const text = tokens[i].text;
|
|
660
1585
|
if (i > idx && (GIT_TOKEN_RE.test(text) || NON_GIT_HEAD_TOKENS.has(text))) {
|
|
661
1586
|
return idx + 1;
|
|
662
1587
|
}
|
|
663
1588
|
for (let k = 0; k < text.length; k++) {
|
|
664
|
-
|
|
665
|
-
if (escaped) {
|
|
666
|
-
escaped = false;
|
|
667
|
-
continue;
|
|
668
|
-
}
|
|
669
|
-
if (state === "'") {
|
|
670
|
-
if (c === "'")
|
|
671
|
-
state = "";
|
|
672
|
-
continue;
|
|
673
|
-
}
|
|
674
|
-
if (state === '"') {
|
|
675
|
-
if (c === "\\")
|
|
676
|
-
escaped = true;
|
|
677
|
-
else if (c === '"')
|
|
678
|
-
state = "";
|
|
679
|
-
continue;
|
|
680
|
-
}
|
|
681
|
-
if (c === "\\")
|
|
682
|
-
escaped = true;
|
|
683
|
-
else if (c === "'" || c === '"')
|
|
684
|
-
state = c;
|
|
1589
|
+
quote.step(text[k]);
|
|
685
1590
|
}
|
|
686
1591
|
// A backslash pending at a token's end would escape the separating
|
|
687
1592
|
// whitespace itself — b093911d's class, not continued here.
|
|
688
|
-
|
|
689
|
-
if (state === "")
|
|
1593
|
+
quote.resetEscape();
|
|
1594
|
+
if (quote.state === "")
|
|
690
1595
|
return i + 1;
|
|
691
1596
|
}
|
|
692
1597
|
// Ran out of tokens with a quote still open: unterminated. Preserve the
|
|
@@ -723,7 +1628,14 @@ function tokenizeWithOffsets(s) {
|
|
|
723
1628
|
function canonicalizeSegment(segmentText) {
|
|
724
1629
|
const tokens = tokenizeWithOffsets(segmentText);
|
|
725
1630
|
if (tokens.length === 0) {
|
|
726
|
-
return {
|
|
1631
|
+
return {
|
|
1632
|
+
text: segmentText,
|
|
1633
|
+
targetDir: null,
|
|
1634
|
+
targetBase: null,
|
|
1635
|
+
identityTargetDir: null,
|
|
1636
|
+
identityTargetBase: null,
|
|
1637
|
+
isGit: false,
|
|
1638
|
+
};
|
|
727
1639
|
}
|
|
728
1640
|
let idx = 0;
|
|
729
1641
|
let envTargetDir = null;
|
|
@@ -783,7 +1695,14 @@ function canonicalizeSegment(segmentText) {
|
|
|
783
1695
|
}
|
|
784
1696
|
const headTok = tokens[idx]?.text;
|
|
785
1697
|
if (headTok === undefined) {
|
|
786
|
-
return {
|
|
1698
|
+
return {
|
|
1699
|
+
text: segmentText,
|
|
1700
|
+
targetDir: null,
|
|
1701
|
+
targetBase: null,
|
|
1702
|
+
identityTargetDir: null,
|
|
1703
|
+
identityTargetBase: null,
|
|
1704
|
+
isGit: false,
|
|
1705
|
+
};
|
|
787
1706
|
}
|
|
788
1707
|
if (GIT_TOKEN_RE.test(headTok)) {
|
|
789
1708
|
idx += 1; // consume the git token (whatever spelling matched GIT_TOKEN_RE)
|
|
@@ -791,7 +1710,14 @@ function canonicalizeSegment(segmentText) {
|
|
|
791
1710
|
if (gitOpts.malformed || gitOpts.idx >= tokens.length) {
|
|
792
1711
|
// Malformed global option (missing required value) or no subcommand
|
|
793
1712
|
// token left: nothing safe to canonicalise. Leave untouched.
|
|
794
|
-
return {
|
|
1713
|
+
return {
|
|
1714
|
+
text: segmentText,
|
|
1715
|
+
targetDir: null,
|
|
1716
|
+
targetBase: null,
|
|
1717
|
+
identityTargetDir: null,
|
|
1718
|
+
identityTargetBase: null,
|
|
1719
|
+
isGit: false,
|
|
1720
|
+
};
|
|
795
1721
|
}
|
|
796
1722
|
const subcommandTok = tokens[gitOpts.idx];
|
|
797
1723
|
const rewritten = `git ${subcommandTok.text}${segmentText.slice(subcommandTok.end)}`;
|
|
@@ -805,7 +1731,50 @@ function canonicalizeSegment(segmentText) {
|
|
|
805
1731
|
!path.isAbsolute(gitOpts.targetDir)
|
|
806
1732
|
? envTargetDir
|
|
807
1733
|
: null;
|
|
808
|
-
|
|
1734
|
+
// D-017: the identity pair mirrors targetDir/targetBase's composition
|
|
1735
|
+
// exactly, but is fed by `relocateTargetDir` (repo-relocating flags
|
|
1736
|
+
// only — `-C`/`--git-dir`, never `--work-tree`) instead of
|
|
1737
|
+
// `gitOpts.targetDir` (which still includes `--work-tree`).
|
|
1738
|
+
//
|
|
1739
|
+
// D-018 (fix round 3): a same-invocation wrapping `env -C`/`--chdir`
|
|
1740
|
+
// PLUS the git invocation's OWN absolute repo-relocating flag are two
|
|
1741
|
+
// repo-relocating options on one invocation, the same "more than one"
|
|
1742
|
+
// class `noteRelocatingOption` (in `peelGitGlobalOptions`) closes for
|
|
1743
|
+
// git's own flags — extended here across the `env -C` wrap. When they
|
|
1744
|
+
// diverge (different resolved directories), git's own flag DOES
|
|
1745
|
+
// deterministically win in reality, but this module deliberately does
|
|
1746
|
+
// not encode that composition rule (`computeSegmentTarget`'s own
|
|
1747
|
+
// comment on why path composition is out of scope) — `null` (cwd
|
|
1748
|
+
// fallback, D-003) rather than keep preferring git's own flag as
|
|
1749
|
+
// before this fix. An identical pair (`env -C X git -C X ...`) is the
|
|
1750
|
+
// same idempotent-repeat allowance as `noteRelocatingOption`'s single-
|
|
1751
|
+
// function case, so it stays `X`, not `null`. The pre-existing
|
|
1752
|
+
// RELATIVE-own-target + env-base mix is untouched by this check —
|
|
1753
|
+
// `identityTargetBase` below already resolves that class fully to
|
|
1754
|
+
// `null` via `computeSegmentTarget`'s early return; this new check
|
|
1755
|
+
// only has any effect when `gitOpts.relocateTargetDir` is ABSOLUTE
|
|
1756
|
+
// (`path.isAbsolute` guards it out for the relative case, the same
|
|
1757
|
+
// condition `identityTargetBase` below already uses).
|
|
1758
|
+
const relocateEnvDiverges = gitOpts.relocateTargetDir !== null &&
|
|
1759
|
+
envTargetDir !== null &&
|
|
1760
|
+
path.isAbsolute(gitOpts.relocateTargetDir) &&
|
|
1761
|
+
gitOpts.relocateTargetDir !== envTargetDir;
|
|
1762
|
+
const identityTargetDir = relocateEnvDiverges
|
|
1763
|
+
? null
|
|
1764
|
+
: (gitOpts.relocateTargetDir ?? envTargetDir);
|
|
1765
|
+
const identityTargetBase = gitOpts.relocateTargetDir !== null &&
|
|
1766
|
+
envTargetDir !== null &&
|
|
1767
|
+
!path.isAbsolute(gitOpts.relocateTargetDir)
|
|
1768
|
+
? envTargetDir
|
|
1769
|
+
: null;
|
|
1770
|
+
return {
|
|
1771
|
+
text: rewritten,
|
|
1772
|
+
targetDir,
|
|
1773
|
+
targetBase,
|
|
1774
|
+
identityTargetDir,
|
|
1775
|
+
identityTargetBase,
|
|
1776
|
+
isGit: true,
|
|
1777
|
+
};
|
|
809
1778
|
}
|
|
810
1779
|
if (NON_GIT_HEAD_TOKENS.has(headTok)) {
|
|
811
1780
|
// D-001 + fix round 2 (finding F2): `gh` / `npm` / `harness` get
|
|
@@ -836,15 +1805,36 @@ function canonicalizeSegment(segmentText) {
|
|
|
836
1805
|
// just the head would silently drop whatever wrapper preceded it for
|
|
837
1806
|
// no benefit. Leave untouched, same fail-safe shape as the git
|
|
838
1807
|
// malformed/no-subcommand case above.
|
|
839
|
-
return {
|
|
1808
|
+
return {
|
|
1809
|
+
text: segmentText,
|
|
1810
|
+
targetDir: null,
|
|
1811
|
+
targetBase: null,
|
|
1812
|
+
identityTargetDir: null,
|
|
1813
|
+
identityTargetBase: null,
|
|
1814
|
+
isGit: false,
|
|
1815
|
+
};
|
|
840
1816
|
}
|
|
841
1817
|
const rewritten = tokens
|
|
842
1818
|
.slice(idx)
|
|
843
1819
|
.map((t) => t.text)
|
|
844
1820
|
.join(" ");
|
|
845
|
-
return {
|
|
1821
|
+
return {
|
|
1822
|
+
text: rewritten,
|
|
1823
|
+
targetDir: null,
|
|
1824
|
+
targetBase: null,
|
|
1825
|
+
identityTargetDir: null,
|
|
1826
|
+
identityTargetBase: null,
|
|
1827
|
+
isGit: false,
|
|
1828
|
+
};
|
|
846
1829
|
}
|
|
847
|
-
return {
|
|
1830
|
+
return {
|
|
1831
|
+
text: segmentText,
|
|
1832
|
+
targetDir: null,
|
|
1833
|
+
targetBase: null,
|
|
1834
|
+
identityTargetDir: null,
|
|
1835
|
+
identityTargetBase: null,
|
|
1836
|
+
isGit: false,
|
|
1837
|
+
};
|
|
848
1838
|
}
|
|
849
1839
|
/**
|
|
850
1840
|
* Peel `env`'s own leading flags and `VAR=value` assignments. The flag-
|
|
@@ -1097,36 +2087,121 @@ function parentIfDotGit(dir) {
|
|
|
1097
2087
|
* adjacent to `git`), tracking the first `-C` / `--work-tree` /
|
|
1098
2088
|
* `--git-dir` target directory encountered (excluding a `~`-prefixed
|
|
1099
2089
|
* value — F5, treated as if absent, but still consumed as an option so
|
|
1100
|
-
* parsing continues past it)
|
|
1101
|
-
*
|
|
1102
|
-
*
|
|
2090
|
+
* parsing continues past it) as `targetDir` — the OLD, UNCONSUMED
|
|
2091
|
+
* aggregate's own extraction (module header STATUS note), UNCHANGED by
|
|
2092
|
+
* the fix below. `malformed: true` means a value-requiring option was
|
|
2093
|
+
* missing its value — the caller bails without rewriting rather than
|
|
2094
|
+
* guess.
|
|
2095
|
+
*
|
|
2096
|
+
* `relocateTargetDir` (D-017, fix round 2, run `2026-08-02-per-repo-
|
|
2097
|
+
* gate-scoping-redesign`): a SEPARATE tracker, fed ONLY by the
|
|
2098
|
+
* repo-relocating flags `-C` and `--git-dir` — NEVER `--work-tree`.
|
|
2099
|
+
* `--work-tree` sets the working tree but does NOT relocate git's
|
|
2100
|
+
* `--git-dir` search; `git push`/`log`/`status` under `--work-tree=<B>`
|
|
2101
|
+
* alone still operate on whatever repo the CURRENT directory (or an
|
|
2102
|
+
* actual `-C`/`--git-dir`) names, not `<B>`. Pass-2 review measured this
|
|
2103
|
+
* as a live cross-repo fail-open once `--work-tree` was folded into the
|
|
2104
|
+
* same `targetDir` bucket as `-C`/`--git-dir` and that bucket fed
|
|
2105
|
+
* `CommandSegment.ownTarget`'s REPLACE attribution (`computeSegmentTarget`
|
|
2106
|
+
* below, `src/runtime/intercept.ts`'s `resolveAttributedContexts`,
|
|
2107
|
+
* D-011): `git --work-tree=<B> push`, cwd = A, was attributed to B alone,
|
|
2108
|
+
* dropping the cwd demand a real `git push` there still has to satisfy.
|
|
2109
|
+
* `relocateTargetDir` is what `computeSegmentTarget` now consults for a
|
|
2110
|
+
* git segment's `ownTarget` — see that function and `canonicalizeSegment`
|
|
2111
|
+
* below. Kept as a distinct field (a whitelist of repo-relocating flags),
|
|
2112
|
+
* not a `--work-tree` special-case, so a future path-valued-but-repo-
|
|
2113
|
+
* neutral git global option cannot silently reopen this class by being
|
|
2114
|
+
* folded into `targetDir` the way `--work-tree` originally was.
|
|
2115
|
+
*
|
|
2116
|
+
* D-018 (fix round 3, pass-3 CRITICAL): `relocateTargetDir` is set ONLY
|
|
2117
|
+
* when the invocation carries EXACTLY ONE recognised, fully-resolved
|
|
2118
|
+
* repo-relocating option (a single `-C` or a single `--git-dir`/
|
|
2119
|
+
* `--git-dir=`). Pass-3 review measured (against real git 2.34.1) that
|
|
2120
|
+
* `git -C <forge> -C <realA> push` attributes to the FIRST `-C` here
|
|
2121
|
+
* while git composes ALL of them cumulatively and actually operates in
|
|
2122
|
+
* `<realA>` — the same first-token-wins gap spans `-C` combined with
|
|
2123
|
+
* `--git-dir`. `noteRelocatingOption` below counts every RESOLVED
|
|
2124
|
+
* (non-`~`) occurrence of either flag across the whole invocation; the
|
|
2125
|
+
* second (or later) occurrence forces `relocateTargetDir` to `null` and
|
|
2126
|
+
* LOCKS it there for the rest of this call — UNLESS its value is an
|
|
2127
|
+
* exact repeat of the one already recorded (idempotent, e.g. `-C X -C
|
|
2128
|
+
* X`), which is not new information and does not trigger the lock. This
|
|
2129
|
+
* is a deliberate under-approximation of git's real `-C`-chains-relative-
|
|
2130
|
+
* to-the-previous-`-C` composition rule (the module has never encoded
|
|
2131
|
+
* that, by design — see `computeSegmentTarget`'s own comment on why
|
|
2132
|
+
* composing paths is not this module's job): ANY invocation this module
|
|
2133
|
+
* cannot resolve to a single, unambiguous flag falls back to `null` here,
|
|
2134
|
+
* which downstream (`computeSegmentTarget`) means "no own target" and the
|
|
2135
|
+
* cwd context is demanded instead — exactly the shipped, pre-this-run
|
|
2136
|
+
* semantics, never a forged first-token guess.
|
|
2137
|
+
*
|
|
2138
|
+
* D-021 (UNIVERSAL-ADDITIVE, operator decision, run 2026-08-02-per-repo-
|
|
2139
|
+
* gate-scoping-redesign): both the D-017 `--work-tree` exclusion and the
|
|
2140
|
+
* D-018 multi-option lock above were originally the SAFETY mechanism
|
|
2141
|
+
* (the thing standing between an attacker-composed invocation and a
|
|
2142
|
+
* forged REPLACE attribution); under D-021, `src/runtime/intercept.ts`'s
|
|
2143
|
+
* `resolveAttributedContexts` never replaces the cwd demand at all, so
|
|
2144
|
+
* neither exclusion can be bypassed into a weaker-than-shipped gate any
|
|
2145
|
+
* more — the cwd demand is never dropped regardless of what
|
|
2146
|
+
* `relocateTargetDir` resolves to. Both stay in place as PRECISION
|
|
2147
|
+
* (avoiding a spurious extra demand for an ambiguous or non-relocating
|
|
2148
|
+
* target), not as the security boundary; see `resolveAttributedContexts`
|
|
2149
|
+
* itself for where that boundary now lives.
|
|
1103
2150
|
*/
|
|
1104
2151
|
function peelGitGlobalOptions(tokens, startIdx) {
|
|
1105
2152
|
let idx = startIdx;
|
|
1106
2153
|
let targetDir = null;
|
|
2154
|
+
let relocateTargetDir = null;
|
|
2155
|
+
let relocateResolvedCount = 0;
|
|
2156
|
+
let relocateAmbiguous = false;
|
|
2157
|
+
// D-018: record one repo-relocating flag's resolved value (`null` for a
|
|
2158
|
+
// `~`-prefixed value, which — same as the pre-fix code — never sets or
|
|
2159
|
+
// counts against `relocateTargetDir` at all, D-003's "treated as if
|
|
2160
|
+
// absent"). The SECOND (or later) resolved occurrence nulls and LOCKS
|
|
2161
|
+
// `relocateTargetDir`, unless it is an exact repeat of the value
|
|
2162
|
+
// already recorded (idempotent, e.g. `-C X -C X` or `-C X --git-dir
|
|
2163
|
+
// X/.git`), which stays a single value rather than tripping the lock —
|
|
2164
|
+
// "im Zweifel null" still applies to every OTHER two-or-more case.
|
|
2165
|
+
function noteRelocatingOption(resolved) {
|
|
2166
|
+
if (resolved === null)
|
|
2167
|
+
return;
|
|
2168
|
+
relocateResolvedCount += 1;
|
|
2169
|
+
if (relocateAmbiguous)
|
|
2170
|
+
return;
|
|
2171
|
+
if (relocateResolvedCount === 1) {
|
|
2172
|
+
relocateTargetDir = resolved;
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
if (resolved === relocateTargetDir)
|
|
2176
|
+
return; // idempotent repeat
|
|
2177
|
+
relocateTargetDir = null;
|
|
2178
|
+
relocateAmbiguous = true;
|
|
2179
|
+
}
|
|
1107
2180
|
while (idx < tokens.length) {
|
|
1108
2181
|
const t = tokens[idx].text;
|
|
1109
2182
|
if (t === "-C") {
|
|
1110
2183
|
const dir = tokens[idx + 1]?.text;
|
|
1111
2184
|
if (dir === undefined)
|
|
1112
|
-
return { idx, targetDir, malformed: true };
|
|
2185
|
+
return { idx, targetDir, relocateTargetDir, malformed: true };
|
|
1113
2186
|
if (targetDir === null && !isTildeTarget(dir))
|
|
1114
2187
|
targetDir = dir;
|
|
2188
|
+
noteRelocatingOption(isTildeTarget(dir) ? null : dir);
|
|
1115
2189
|
idx += 2;
|
|
1116
2190
|
continue;
|
|
1117
2191
|
}
|
|
1118
2192
|
if (t === "-c") {
|
|
1119
2193
|
if (tokens[idx + 1] === undefined)
|
|
1120
|
-
return { idx, targetDir, malformed: true };
|
|
2194
|
+
return { idx, targetDir, relocateTargetDir, malformed: true };
|
|
1121
2195
|
idx += 2;
|
|
1122
2196
|
continue;
|
|
1123
2197
|
}
|
|
1124
2198
|
if (t === "--git-dir") {
|
|
1125
2199
|
const dir = tokens[idx + 1]?.text;
|
|
1126
2200
|
if (dir === undefined)
|
|
1127
|
-
return { idx, targetDir, malformed: true };
|
|
2201
|
+
return { idx, targetDir, relocateTargetDir, malformed: true };
|
|
1128
2202
|
if (targetDir === null && !isTildeTarget(dir))
|
|
1129
2203
|
targetDir = parentIfDotGit(dir);
|
|
2204
|
+
noteRelocatingOption(isTildeTarget(dir) ? null : parentIfDotGit(dir));
|
|
1130
2205
|
idx += 2;
|
|
1131
2206
|
continue;
|
|
1132
2207
|
}
|
|
@@ -1134,13 +2209,17 @@ function peelGitGlobalOptions(tokens, startIdx) {
|
|
|
1134
2209
|
const dir = t.slice("--git-dir=".length);
|
|
1135
2210
|
if (targetDir === null && !isTildeTarget(dir))
|
|
1136
2211
|
targetDir = parentIfDotGit(dir);
|
|
2212
|
+
noteRelocatingOption(isTildeTarget(dir) ? null : parentIfDotGit(dir));
|
|
1137
2213
|
idx += 1;
|
|
1138
2214
|
continue;
|
|
1139
2215
|
}
|
|
1140
2216
|
if (t === "--work-tree") {
|
|
2217
|
+
// D-017: `--work-tree` still feeds the OLD `targetDir` aggregate
|
|
2218
|
+
// (unchanged, unconsumed by any gate) but deliberately NEVER
|
|
2219
|
+
// `relocateTargetDir` — see this function's own doc comment.
|
|
1141
2220
|
const dir = tokens[idx + 1]?.text;
|
|
1142
2221
|
if (dir === undefined)
|
|
1143
|
-
return { idx, targetDir, malformed: true };
|
|
2222
|
+
return { idx, targetDir, relocateTargetDir, malformed: true };
|
|
1144
2223
|
if (targetDir === null && !isTildeTarget(dir))
|
|
1145
2224
|
targetDir = dir;
|
|
1146
2225
|
idx += 2;
|
|
@@ -1163,7 +2242,7 @@ function peelGitGlobalOptions(tokens, startIdx) {
|
|
|
1163
2242
|
}
|
|
1164
2243
|
if (t === "--namespace") {
|
|
1165
2244
|
if (tokens[idx + 1] === undefined)
|
|
1166
|
-
return { idx, targetDir, malformed: true };
|
|
2245
|
+
return { idx, targetDir, relocateTargetDir, malformed: true };
|
|
1167
2246
|
idx += 2;
|
|
1168
2247
|
continue;
|
|
1169
2248
|
}
|
|
@@ -1177,6 +2256,6 @@ function peelGitGlobalOptions(tokens, startIdx) {
|
|
|
1177
2256
|
}
|
|
1178
2257
|
break;
|
|
1179
2258
|
}
|
|
1180
|
-
return { idx, targetDir, malformed: false };
|
|
2259
|
+
return { idx, targetDir, relocateTargetDir, malformed: false };
|
|
1181
2260
|
}
|
|
1182
2261
|
//# sourceMappingURL=command-normalize.js.map
|