@cad0p/pi-steering 0.1.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.
Files changed (246) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +29 -0
  3. package/README.md +920 -0
  4. package/dist/__test-helpers__.d.ts +95 -0
  5. package/dist/__test-helpers__.d.ts.map +1 -0
  6. package/dist/__test-helpers__.js +174 -0
  7. package/dist/__test-helpers__.js.map +1 -0
  8. package/dist/bin/pi-steering.d.ts +9 -0
  9. package/dist/bin/pi-steering.d.ts.map +1 -0
  10. package/dist/bin/pi-steering.js +506 -0
  11. package/dist/bin/pi-steering.js.map +1 -0
  12. package/dist/compat.d.ts +50 -0
  13. package/dist/compat.d.ts.map +1 -0
  14. package/dist/compat.js +180 -0
  15. package/dist/compat.js.map +1 -0
  16. package/dist/defaults.d.ts +61 -0
  17. package/dist/defaults.d.ts.map +1 -0
  18. package/dist/defaults.js +106 -0
  19. package/dist/defaults.js.map +1 -0
  20. package/dist/define-config.d.ts +235 -0
  21. package/dist/define-config.d.ts.map +1 -0
  22. package/dist/define-config.js +118 -0
  23. package/dist/define-config.js.map +1 -0
  24. package/dist/define-predicate.d.ts +49 -0
  25. package/dist/define-predicate.d.ts.map +1 -0
  26. package/dist/define-predicate.js +35 -0
  27. package/dist/define-predicate.js.map +1 -0
  28. package/dist/evaluator-internals/context.d.ts +186 -0
  29. package/dist/evaluator-internals/context.d.ts.map +1 -0
  30. package/dist/evaluator-internals/context.js +219 -0
  31. package/dist/evaluator-internals/context.js.map +1 -0
  32. package/dist/evaluator-internals/override.d.ts +19 -0
  33. package/dist/evaluator-internals/override.d.ts.map +1 -0
  34. package/dist/evaluator-internals/override.js +79 -0
  35. package/dist/evaluator-internals/override.js.map +1 -0
  36. package/dist/evaluator-internals/predicates.d.ts +152 -0
  37. package/dist/evaluator-internals/predicates.d.ts.map +1 -0
  38. package/dist/evaluator-internals/predicates.js +849 -0
  39. package/dist/evaluator-internals/predicates.js.map +1 -0
  40. package/dist/evaluator-internals/speculative-synthesis.d.ts +102 -0
  41. package/dist/evaluator-internals/speculative-synthesis.d.ts.map +1 -0
  42. package/dist/evaluator-internals/speculative-synthesis.js +101 -0
  43. package/dist/evaluator-internals/speculative-synthesis.js.map +1 -0
  44. package/dist/evaluator.d.ts +101 -0
  45. package/dist/evaluator.d.ts.map +1 -0
  46. package/dist/evaluator.js +714 -0
  47. package/dist/evaluator.js.map +1 -0
  48. package/dist/helpers/walker-unknown-cwd-reason.d.ts +68 -0
  49. package/dist/helpers/walker-unknown-cwd-reason.d.ts.map +1 -0
  50. package/dist/helpers/walker-unknown-cwd-reason.js +73 -0
  51. package/dist/helpers/walker-unknown-cwd-reason.js.map +1 -0
  52. package/dist/index.d.ts +42 -0
  53. package/dist/index.d.ts.map +1 -0
  54. package/dist/index.js +93 -0
  55. package/dist/index.js.map +1 -0
  56. package/dist/internal/drop-unused-observers.d.ts +43 -0
  57. package/dist/internal/drop-unused-observers.d.ts.map +1 -0
  58. package/dist/internal/drop-unused-observers.js +50 -0
  59. package/dist/internal/drop-unused-observers.js.map +1 -0
  60. package/dist/internal/finalize-plugin-state.d.ts +22 -0
  61. package/dist/internal/finalize-plugin-state.d.ts.map +1 -0
  62. package/dist/internal/finalize-plugin-state.js +14 -0
  63. package/dist/internal/finalize-plugin-state.js.map +1 -0
  64. package/dist/internal/merge-observers.d.ts +30 -0
  65. package/dist/internal/merge-observers.d.ts.map +1 -0
  66. package/dist/internal/merge-observers.js +27 -0
  67. package/dist/internal/merge-observers.js.map +1 -0
  68. package/dist/internal/pattern-utils.d.ts +33 -0
  69. package/dist/internal/pattern-utils.d.ts.map +1 -0
  70. package/dist/internal/pattern-utils.js +20 -0
  71. package/dist/internal/pattern-utils.js.map +1 -0
  72. package/dist/internal/ref-text.d.ts +11 -0
  73. package/dist/internal/ref-text.d.ts.map +1 -0
  74. package/dist/internal/ref-text.js +15 -0
  75. package/dist/internal/ref-text.js.map +1 -0
  76. package/dist/internal/session-runtime.d.ts +44 -0
  77. package/dist/internal/session-runtime.d.ts.map +1 -0
  78. package/dist/internal/session-runtime.js +143 -0
  79. package/dist/internal/session-runtime.js.map +1 -0
  80. package/dist/internal/watch-matcher.d.ts +56 -0
  81. package/dist/internal/watch-matcher.d.ts.map +1 -0
  82. package/dist/internal/watch-matcher.js +179 -0
  83. package/dist/internal/watch-matcher.js.map +1 -0
  84. package/dist/loader.d.ts +97 -0
  85. package/dist/loader.d.ts.map +1 -0
  86. package/dist/loader.js +527 -0
  87. package/dist/loader.js.map +1 -0
  88. package/dist/observer-dispatcher.d.ts +69 -0
  89. package/dist/observer-dispatcher.d.ts.map +1 -0
  90. package/dist/observer-dispatcher.js +184 -0
  91. package/dist/observer-dispatcher.js.map +1 -0
  92. package/dist/plugin-merger.d.ts +175 -0
  93. package/dist/plugin-merger.d.ts.map +1 -0
  94. package/dist/plugin-merger.js +450 -0
  95. package/dist/plugin-merger.js.map +1 -0
  96. package/dist/plugins/git/branch-tracker.d.ts +85 -0
  97. package/dist/plugins/git/branch-tracker.d.ts.map +1 -0
  98. package/dist/plugins/git/branch-tracker.js +181 -0
  99. package/dist/plugins/git/branch-tracker.js.map +1 -0
  100. package/dist/plugins/git/cwd-extensions.d.ts +10 -0
  101. package/dist/plugins/git/cwd-extensions.d.ts.map +1 -0
  102. package/dist/plugins/git/cwd-extensions.js +155 -0
  103. package/dist/plugins/git/cwd-extensions.js.map +1 -0
  104. package/dist/plugins/git/git-ops.d.ts +89 -0
  105. package/dist/plugins/git/git-ops.d.ts.map +1 -0
  106. package/dist/plugins/git/git-ops.js +107 -0
  107. package/dist/plugins/git/git-ops.js.map +1 -0
  108. package/dist/plugins/git/index.d.ts +225 -0
  109. package/dist/plugins/git/index.d.ts.map +1 -0
  110. package/dist/plugins/git/index.js +52 -0
  111. package/dist/plugins/git/index.js.map +1 -0
  112. package/dist/plugins/git/predicates.d.ts +312 -0
  113. package/dist/plugins/git/predicates.d.ts.map +1 -0
  114. package/dist/plugins/git/predicates.js +580 -0
  115. package/dist/plugins/git/predicates.js.map +1 -0
  116. package/dist/plugins/git/rules.d.ts +209 -0
  117. package/dist/plugins/git/rules.d.ts.map +1 -0
  118. package/dist/plugins/git/rules.js +267 -0
  119. package/dist/plugins/git/rules.js.map +1 -0
  120. package/dist/schema.d.ts +1799 -0
  121. package/dist/schema.d.ts.map +1 -0
  122. package/dist/schema.js +4 -0
  123. package/dist/schema.js.map +1 -0
  124. package/dist/testing/index.d.ts +621 -0
  125. package/dist/testing/index.d.ts.map +1 -0
  126. package/dist/testing/index.js +747 -0
  127. package/dist/testing/index.js.map +1 -0
  128. package/examples/README.md +83 -0
  129. package/examples/combined-git-discipline/README.md +52 -0
  130. package/examples/combined-git-discipline/node_modules/.bin/pi-steering +43 -0
  131. package/examples/combined-git-discipline/package.json +18 -0
  132. package/examples/combined-git-discipline/steering.json +27 -0
  133. package/examples/combined-git-discipline/steering.test.ts +42 -0
  134. package/examples/combined-git-discipline/steering.ts +64 -0
  135. package/examples/combined-git-discipline/tsconfig.json +7 -0
  136. package/examples/draft-prs-only/README.md +42 -0
  137. package/examples/draft-prs-only/node_modules/.bin/pi-steering +43 -0
  138. package/examples/draft-prs-only/package.json +18 -0
  139. package/examples/draft-prs-only/steering.json +12 -0
  140. package/examples/draft-prs-only/steering.test.ts +48 -0
  141. package/examples/draft-prs-only/steering.ts +36 -0
  142. package/examples/draft-prs-only/tsconfig.json +7 -0
  143. package/examples/dynamic-reason-runtime-cwd/README.md +148 -0
  144. package/examples/dynamic-reason-runtime-cwd/node_modules/.bin/pi-steering +43 -0
  145. package/examples/dynamic-reason-runtime-cwd/package.json +18 -0
  146. package/examples/dynamic-reason-runtime-cwd/steering.test.ts +130 -0
  147. package/examples/dynamic-reason-runtime-cwd/steering.ts +82 -0
  148. package/examples/dynamic-reason-runtime-cwd/tsconfig.json +7 -0
  149. package/examples/force-push-strict/README.md +75 -0
  150. package/examples/force-push-strict/node_modules/.bin/pi-steering +43 -0
  151. package/examples/force-push-strict/package.json +18 -0
  152. package/examples/force-push-strict/steering.json +12 -0
  153. package/examples/force-push-strict/steering.test.ts +51 -0
  154. package/examples/force-push-strict/steering.ts +54 -0
  155. package/examples/force-push-strict/tsconfig.json +7 -0
  156. package/examples/no-amend/README.md +52 -0
  157. package/examples/no-amend/node_modules/.bin/pi-steering +43 -0
  158. package/examples/no-amend/package.json +18 -0
  159. package/examples/no-amend/steering.cwd-scoped.json +12 -0
  160. package/examples/no-amend/steering.json +11 -0
  161. package/examples/no-amend/steering.test.ts +49 -0
  162. package/examples/no-amend/steering.ts +38 -0
  163. package/examples/no-amend/tsconfig.json +7 -0
  164. package/examples/work-item-plugin/README.md +103 -0
  165. package/examples/work-item-plugin/node_modules/.bin/pi-steering +43 -0
  166. package/examples/work-item-plugin/package.json +19 -0
  167. package/examples/work-item-plugin/src/index.test.ts +168 -0
  168. package/examples/work-item-plugin/src/index.ts +122 -0
  169. package/examples/work-item-plugin/src/observers/npm-test-tracker.test.ts +95 -0
  170. package/examples/work-item-plugin/src/observers/npm-test-tracker.ts +88 -0
  171. package/examples/work-item-plugin/src/observers/retest-required-tracker.test.ts +81 -0
  172. package/examples/work-item-plugin/src/observers/retest-required-tracker.ts +81 -0
  173. package/examples/work-item-plugin/src/predicates/work-item-format.test.ts +157 -0
  174. package/examples/work-item-plugin/src/predicates/work-item-format.ts +99 -0
  175. package/examples/work-item-plugin/src/rules/commit-description-check.test.ts +107 -0
  176. package/examples/work-item-plugin/src/rules/commit-description-check.ts +83 -0
  177. package/examples/work-item-plugin/src/rules/commit-requires-work-item.test.ts +79 -0
  178. package/examples/work-item-plugin/src/rules/commit-requires-work-item.ts +56 -0
  179. package/examples/work-item-plugin/src/rules/push-requires-tests.test.ts +245 -0
  180. package/examples/work-item-plugin/src/rules/push-requires-tests.ts +62 -0
  181. package/examples/work-item-plugin/tsconfig.json +8 -0
  182. package/package.json +88 -0
  183. package/skills/steering-authoring/SKILL.md +102 -0
  184. package/src/INVARIANTS.md +116 -0
  185. package/src/__test-helpers__.ts +243 -0
  186. package/src/bin/pi-steering.test.ts +920 -0
  187. package/src/bin/pi-steering.ts +584 -0
  188. package/src/compat.test.ts +431 -0
  189. package/src/compat.ts +282 -0
  190. package/src/defaults.test.ts +585 -0
  191. package/src/defaults.ts +148 -0
  192. package/src/define-config.test.ts +888 -0
  193. package/src/define-config.ts +375 -0
  194. package/src/define-predicate.test.ts +70 -0
  195. package/src/define-predicate.ts +57 -0
  196. package/src/evaluator-internals/context.ts +290 -0
  197. package/src/evaluator-internals/override.test.ts +99 -0
  198. package/src/evaluator-internals/override.ts +83 -0
  199. package/src/evaluator-internals/predicates.ts +1037 -0
  200. package/src/evaluator-internals/speculative-synthesis.test.ts +411 -0
  201. package/src/evaluator-internals/speculative-synthesis.ts +199 -0
  202. package/src/evaluator.test.ts +6296 -0
  203. package/src/evaluator.ts +1106 -0
  204. package/src/factory-time-load.test.ts +630 -0
  205. package/src/helpers/walker-unknown-cwd-reason.test.ts +73 -0
  206. package/src/helpers/walker-unknown-cwd-reason.ts +83 -0
  207. package/src/index.test.ts +836 -0
  208. package/src/index.ts +229 -0
  209. package/src/internal/drop-unused-observers.test.ts +141 -0
  210. package/src/internal/drop-unused-observers.ts +81 -0
  211. package/src/internal/finalize-plugin-state.ts +43 -0
  212. package/src/internal/merge-observers.ts +51 -0
  213. package/src/internal/pattern-utils.test.ts +52 -0
  214. package/src/internal/pattern-utils.ts +38 -0
  215. package/src/internal/ref-text.test.ts +54 -0
  216. package/src/internal/ref-text.ts +17 -0
  217. package/src/internal/session-runtime.test.ts +629 -0
  218. package/src/internal/session-runtime.ts +201 -0
  219. package/src/internal/watch-matcher.test.ts +326 -0
  220. package/src/internal/watch-matcher.ts +200 -0
  221. package/src/jsdoc-propagation.test.ts +277 -0
  222. package/src/loader.test.ts +1098 -0
  223. package/src/loader.ts +581 -0
  224. package/src/not-block-onunknown.test.ts +554 -0
  225. package/src/observer-dispatcher.test.ts +939 -0
  226. package/src/observer-dispatcher.ts +290 -0
  227. package/src/per-predicate-typing.test.ts +361 -0
  228. package/src/plugin-merger.test.ts +790 -0
  229. package/src/plugin-merger.ts +598 -0
  230. package/src/plugins/git/README.md +485 -0
  231. package/src/plugins/git/branch-tracker.test.ts +281 -0
  232. package/src/plugins/git/branch-tracker.ts +187 -0
  233. package/src/plugins/git/cwd-extensions.test.ts +230 -0
  234. package/src/plugins/git/cwd-extensions.ts +160 -0
  235. package/src/plugins/git/git-ops.test.ts +397 -0
  236. package/src/plugins/git/git-ops.ts +173 -0
  237. package/src/plugins/git/index.ts +253 -0
  238. package/src/plugins/git/integration.test.ts +705 -0
  239. package/src/plugins/git/predicates.test.ts +1401 -0
  240. package/src/plugins/git/predicates.ts +693 -0
  241. package/src/plugins/git/rules.test.ts +1001 -0
  242. package/src/plugins/git/rules.ts +307 -0
  243. package/src/schema.test.ts +259 -0
  244. package/src/schema.ts +1963 -0
  245. package/src/testing/index.test.ts +1696 -0
  246. package/src/testing/index.ts +1472 -0
@@ -0,0 +1,230 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Tests for the git plugin's cwd tracker extension
6
+ * (`./cwd-extensions.ts`).
7
+ *
8
+ * These tests walk scripts against a MANUALLY composed cwd tracker -
9
+ * the core `cwdTracker` with the plugin's `gitCwdExtensions` appended
10
+ * under basename `git`. This mirrors exactly what the plugin merger
11
+ * produces at runtime, without pulling the merger into every test.
12
+ * See `./integration.test.ts` for end-to-end behavior through the
13
+ * evaluator.
14
+ */
15
+
16
+ import assert from "node:assert/strict";
17
+ import { describe, it } from "node:test";
18
+ import {
19
+ type CommandRef,
20
+ cwdTracker,
21
+ extractAllCommandsFromAST,
22
+ getBasename,
23
+ getCommandArgs,
24
+ type Modifier,
25
+ parse as parseBash,
26
+ type Tracker,
27
+ walk,
28
+ } from "@cad0p/unbash-walker";
29
+ import { gitCwdExtensions } from "./cwd-extensions.ts";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Harness
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * Build a cwd tracker with the plugin's extensions appended to the
37
+ * core `git` basename slot. The same shape the plugin merger produces
38
+ * at runtime via `composeTracker`.
39
+ */
40
+ function composeCwdWithExtensions(): Tracker<string> {
41
+ const existing = cwdTracker.modifiers["git"];
42
+ const existingList: Modifier<string>[] = Array.isArray(existing)
43
+ ? ([...existing] as Modifier<string>[])
44
+ : existing !== undefined
45
+ ? [existing as Modifier<string>]
46
+ : [];
47
+ return {
48
+ ...cwdTracker,
49
+ modifiers: {
50
+ ...cwdTracker.modifiers,
51
+ git: [
52
+ ...existingList,
53
+ ...(gitCwdExtensions as readonly Modifier<string>[]),
54
+ ],
55
+ },
56
+ };
57
+ }
58
+
59
+ interface WalkedCommand {
60
+ ref: CommandRef;
61
+ text: string;
62
+ cwd: string;
63
+ }
64
+
65
+ function walkCwd(script: string, initialCwd = "/initial"): WalkedCommand[] {
66
+ const tracker = composeCwdWithExtensions();
67
+ const ast = parseBash(script);
68
+ const refs = extractAllCommandsFromAST(ast, script);
69
+ const result = walk(ast, { cwd: initialCwd }, { cwd: tracker }, refs);
70
+ return refs.map((ref) => {
71
+ const snap = result.get(ref);
72
+ return {
73
+ ref,
74
+ text: `${getBasename(ref)} ${getCommandArgs(ref).join(" ")}`.trim(),
75
+ cwd: (snap?.cwd as string | undefined) ?? "<missing>",
76
+ };
77
+ });
78
+ }
79
+
80
+ function cwdOf(walked: WalkedCommand[], prefix: string): string {
81
+ const hit = walked.find((w) => w.text.startsWith(prefix));
82
+ if (!hit) {
83
+ throw new Error(
84
+ `no command starting with "${prefix}" in walked set: ` +
85
+ walked.map((w) => w.text).join(" | "),
86
+ );
87
+ }
88
+ return hit.cwd;
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // --git-dir=
93
+ // ---------------------------------------------------------------------------
94
+
95
+ describe("gitCwdExtensions: --git-dir=PATH", () => {
96
+ it("absolute path replaces the effective cwd", () => {
97
+ const walked = walkCwd("git --git-dir=/b status");
98
+ assert.equal(cwdOf(walked, "git --git-dir"), "/b");
99
+ });
100
+
101
+ it("relative path joins against the current cwd", () => {
102
+ const walked = walkCwd("git --git-dir=subrepo status", "/work");
103
+ assert.equal(cwdOf(walked, "git --git-dir"), "/work/subrepo");
104
+ });
105
+
106
+ it("per-command semantics - does not propagate to the next command", () => {
107
+ // Per-command scope: the modifier applies to THIS command only.
108
+ // The next sibling command sees the pre-modifier cwd.
109
+ const walked = walkCwd("git --git-dir=/b status && git log", "/initial");
110
+ assert.equal(cwdOf(walked, "git --git-dir"), "/b");
111
+ assert.equal(cwdOf(walked, "git log"), "/initial");
112
+ });
113
+
114
+ it("non-static target collapses cwd to unknown", () => {
115
+ // `--git-dir=$V` - the overall Word is non-static via
116
+ // SimpleExpansion. Extension returns undefined; walker emits
117
+ // the cwd tracker's `"unknown"` sentinel.
118
+ const walked = walkCwd("git --git-dir=$V status");
119
+ assert.equal(cwdOf(walked, "git --git-dir"), "unknown");
120
+ });
121
+ });
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // --work-tree=
125
+ // ---------------------------------------------------------------------------
126
+
127
+ describe("gitCwdExtensions: --work-tree=PATH", () => {
128
+ it("absolute path replaces the effective cwd", () => {
129
+ const walked = walkCwd("git --work-tree=/c status");
130
+ assert.equal(cwdOf(walked, "git --work-tree"), "/c");
131
+ });
132
+
133
+ it("relative path joins against the current cwd", () => {
134
+ const walked = walkCwd("git --work-tree=tree status", "/repo");
135
+ assert.equal(cwdOf(walked, "git --work-tree"), "/repo/tree");
136
+ });
137
+
138
+ it("`git --work-tree=$V status` collapses cwd to unknown", () => {
139
+ const walked = walkCwd("git --work-tree=$V status");
140
+ assert.equal(cwdOf(walked, "git --work-tree"), "unknown");
141
+ });
142
+
143
+ it("`--work-tree=/t` is per-command (doesn't propagate)", () => {
144
+ const walked = walkCwd("git --work-tree=/t status && git log", "/initial");
145
+ assert.equal(cwdOf(walked, "git --work-tree"), "/t");
146
+ assert.equal(cwdOf(walked, "git log"), "/initial");
147
+ });
148
+
149
+ it("multiple --git-dir= / --work-tree= flags compose (last absolute wins)", () => {
150
+ const walked = walkCwd("git --git-dir=/a --work-tree=/b status");
151
+ assert.equal(cwdOf(walked, "git --git-dir"), "/b");
152
+ });
153
+ });
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Composition with the core `-C` modifier
157
+ // ---------------------------------------------------------------------------
158
+
159
+ describe("gitCwdExtensions: composition with core -C", () => {
160
+ // Pins the "extension wins" precedence. The walker runs per-command
161
+ // modifiers left-to-right; the plugin merger registers the extension
162
+ // AFTER the core modifier, so the extension's value is applied last.
163
+ // This matches git's documented precedence for --git-dir / --work-tree
164
+ // over -C.
165
+ it("`git -C /a --git-dir=/b status` -> /b (extension wins on absolute)", () => {
166
+ const walked = walkCwd("git -C /a --git-dir=/b status");
167
+ assert.equal(cwdOf(walked, "git -C"), "/b");
168
+ });
169
+
170
+ it("`git --git-dir=/b -C /a status` -> /b (same verdict regardless of source order)", () => {
171
+ // Token order in the source doesn't change the modifier
172
+ // composition order - that's determined by registration in the
173
+ // plugin merger. Both orders produce the same final cwd here.
174
+ const walked = walkCwd("git --git-dir=/b -C /a status");
175
+ assert.equal(cwdOf(walked, "git --git-dir"), "/b");
176
+ });
177
+
178
+ it("`git -C /a --git-dir=b status` -> /a/b (extension joins relative against -C result)", () => {
179
+ // The extension's modifier runs SECOND with the core modifier's
180
+ // output (/a) as its running cwd. A relative --git-dir joins
181
+ // against that, producing /a/b.
182
+ const walked = walkCwd("git -C /a --git-dir=b status");
183
+ assert.equal(cwdOf(walked, "git -C"), "/a/b");
184
+ });
185
+
186
+ it("`git -C /a status` (no extension flag) still routes through the core -C", () => {
187
+ // Regression guard: adding the extension must not disturb
188
+ // bare `-C` behavior.
189
+ const walked = walkCwd("git -C /a status");
190
+ assert.equal(cwdOf(walked, "git -C"), "/a");
191
+ });
192
+ });
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Non-interference
196
+ // ---------------------------------------------------------------------------
197
+
198
+ describe("gitCwdExtensions: non-interference", () => {
199
+ it("`git status` (no cwd flags) preserves the current cwd", () => {
200
+ const walked = walkCwd("git status", "/work");
201
+ assert.equal(cwdOf(walked, "git status"), "/work");
202
+ });
203
+
204
+ it("`git log --grep='--git-dir=foo'` does NOT match inside a quoted arg", () => {
205
+ // The grep value is a SINGLE token whose text starts with
206
+ // `--grep=`, not `--git-dir=`. Regression guard against the
207
+ // naive scan-all-args trap.
208
+ const walked = walkCwd("git log --grep='--git-dir=foo'", "/initial");
209
+ assert.equal(cwdOf(walked, "git log"), "/initial");
210
+ });
211
+ });
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Accepted false-positives
215
+ // ---------------------------------------------------------------------------
216
+
217
+ describe("cwd extensions: accepted false-positives", () => {
218
+ it("`git log --git-dir=/repo` over-matches (post-subcommand flag)", () => {
219
+ // Documented accepted false-positive: we scan all args; real git
220
+ // treats post-subcommand --git-dir= as a pathspec. Over-match rate
221
+ // is low; stopping at subcommand would break -C composition.
222
+ const walked = walkCwd("git log --git-dir=/repo", "/start");
223
+ assert.equal(cwdOf(walked, "git log"), "/repo");
224
+ });
225
+
226
+ it("`git diff -- --git-dir=/x` over-matches (after `--` should be pathspec)", () => {
227
+ const walked = walkCwd("git diff -- --git-dir=/x", "/start");
228
+ assert.equal(cwdOf(walked, "git diff"), "/x");
229
+ });
230
+ });
@@ -0,0 +1,160 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * `cwd` tracker extensions for the git plugin.
6
+ *
7
+ * The built-in `cwdTracker` (in unbash-walker) handles `git -C DIR
8
+ * subcmd`. Two narrower git flags - `--git-dir=/path` and
9
+ * `--work-tree=/path` - express the same "run this command as if the
10
+ * working directory were /path" intent, but weren't modelled in the
11
+ * walker's core to keep its git knowledge minimal. Parsing them lives
12
+ * in the git plugin as a tracker extension: registered under
13
+ * `trackerExtensions.cwd.git`, the modifier layers on top of the core
14
+ * cwd tracker's own `git` per-command modifier.
15
+ *
16
+ * Scope is `per-command` - consistent with `-C` and git's own runtime
17
+ * behavior: these flags affect only the command they're attached to.
18
+ *
19
+ * Composition with `-C`:
20
+ *
21
+ * `git -C /a --git-dir=/b status`
22
+ *
23
+ * The plugin-merger appends this extension's modifier AFTER the core
24
+ * `-C` modifier under the same basename. The walker then runs BOTH
25
+ * modifiers left-to-right against the same arg list, each layering
26
+ * its own contribution on top of the running recorded value:
27
+ *
28
+ * 1. Core `-C /a` modifier (scans pre-subcommand flags only):
29
+ * running cwd "/initial" -> "/a"
30
+ * 2. This `--git-dir=/b` modifier (scans all args for the two
31
+ * specific flag forms):
32
+ * running cwd "/a" -> "/b"
33
+ *
34
+ * The LATER modifier wins on absolute paths; for relative paths it
35
+ * joins against the running value from the previous step. Test
36
+ * expectation for `git -C /a --git-dir=b status` is `/a/b`.
37
+ *
38
+ * Order in the source matters too:
39
+ *
40
+ * - `git --git-dir=/b -C /a status` -> `/b` (core `-C` runs on
41
+ * the pre-sequential value `/initial`, producing `/a`; then
42
+ * this modifier runs against the same args, overriding to
43
+ * `/b`).
44
+ * - `git -C /a --git-dir=/b status` -> `/b` (same left-to-right
45
+ * modifier order; the core scanner produces `/a`, this one
46
+ * overrides to `/b`).
47
+ *
48
+ * In both cases the `--git-dir=` / `--work-tree=` flags win, matching
49
+ * git's documented precedence.
50
+ *
51
+ * Known limitations:
52
+ *
53
+ * - `--git-dir` without an `=` (space-separated form:
54
+ * `git --git-dir /path ...`) is NOT handled. Both git and shell
55
+ * tokenize this as two words; walker modifiers today only see the
56
+ * single `--git-dir` token. Wiring a two-token form is possible
57
+ * but agents overwhelmingly emit the `=` form; follow-up.
58
+ * - Non-static targets (`--git-dir=$VAR`) return `undefined`,
59
+ * collapsing the per-command cwd to `"unknown"`. Predicates then
60
+ * apply `onUnknown`.
61
+ * - `--work-tree` without `--git-dir` is semantically incomplete in
62
+ * real git (the gitdir still points at the default), but for cwd
63
+ * tracking purposes we treat them identically - they both mean
64
+ * "the command's effective cwd is this path". If a rule author
65
+ * needs the distinction (e.g. a repo-path vs tree-path
66
+ * predicate), they can split it in their own plugin.
67
+ *
68
+ * Known accepted false-positives:
69
+ *
70
+ * - `git log --git-dir=/repo` -> cwd recorded as /repo. Real git
71
+ * treats post-subcommand `--git-dir=` as a pathspec, not a
72
+ * global flag. Over-match rate is low in practice (agents emit
73
+ * `git --git-dir=/x log`, not the inverse), and stopping the
74
+ * scan at the first non-flag token would break composition with
75
+ * the core `-C` modifier which iterates the same args list.
76
+ * Accepted as a Phase-4 corner.
77
+ * - `git diff -- --git-dir=/x` -> cwd recorded as /x. Arguments
78
+ * after `--` are always pathspecs in real git. Same mitigation
79
+ * rationale as above; stopping the scan at `--` would still
80
+ * need to run after the core `-C` scanner has finished, not
81
+ * before, to avoid the composition breakage.
82
+ */
83
+
84
+ import * as path from "node:path";
85
+ import type { Word } from "unbash";
86
+ import { isStaticallyResolvable, type Modifier } from "@cad0p/unbash-walker";
87
+
88
+ /** Regex matched against each `git`-command argument token. */
89
+ const GIT_DIR_RE = /^--git-dir=(.+)$/;
90
+ const WORK_TREE_RE = /^--work-tree=(.+)$/;
91
+
92
+ /**
93
+ * Read a word's static value. Falls back to `text` when `value`
94
+ * isn't materialised (pure literal fast path in unbash).
95
+ */
96
+ function wordValue(w: Word | undefined): string | undefined {
97
+ return w?.value ?? w?.text;
98
+ }
99
+
100
+ /**
101
+ * Scan git's pre-subcommand flags for `--git-dir=DIR` / `--work-tree=DIR`
102
+ * and layer the target on top of the current cwd.
103
+ *
104
+ * Stops at the first non-flag token (the subcommand) - identical shape
105
+ * to the core `-C` scanner. Multiple occurrences compose left-to-right
106
+ * (an absolute path replaces, a relative path joins to the running
107
+ * cwd). If we encounter a target we can't statically resolve, we bail
108
+ * returning `undefined`; the walker translates that to the cwd
109
+ * tracker's `"unknown"` sentinel.
110
+ */
111
+ const gitCwdExtension: Modifier<string> = {
112
+ scope: "per-command",
113
+ apply: (args, current) => {
114
+ let cwd = current;
115
+ // Scan ALL args (not just pre-subcommand): we match only the two
116
+ // specific flag forms `--git-dir=X` / `--work-tree=X`, so there's
117
+ // no ambiguity with subcommand arguments. `git log
118
+ // --grep="--git-dir=foo"` is safe because the quoted string is a
119
+ // SINGLE argument whose text starts with `--grep`, not
120
+ // `--git-dir`, so the regex doesn't match.
121
+ //
122
+ // A stop-at-non-flag scan (mirroring the core `-C` modifier)
123
+ // would break composition with `-C`: the walker runs this
124
+ // modifier AGAINST THE SAME args the core `-C` modifier saw, not
125
+ // a reduced arg list. On `git -C /a --git-dir=/b status`, the
126
+ // args are still `[-C, /a, --git-dir=/b, status]`; a stop-at-
127
+ // non-flag scan would terminate at `/a` (the value of `-C`,
128
+ // which doesn't start with `-`) and miss the `--git-dir=` flag.
129
+ for (const w of args) {
130
+ const tok = wordValue(w);
131
+ if (tok === undefined) continue;
132
+
133
+ const gitDirMatch = tok.match(GIT_DIR_RE);
134
+ const workTreeMatch = tok.match(WORK_TREE_RE);
135
+ const match = gitDirMatch ?? workTreeMatch;
136
+ if (!match) continue;
137
+
138
+ // `--git-dir=$VAR` / `--work-tree=$(...)` - the captured
139
+ // piece is there in the raw text, but the overall Word is
140
+ // non-static. Refuse to invent a value.
141
+ if (!isStaticallyResolvable(w)) return undefined;
142
+
143
+ const target = match[1];
144
+ if (target === undefined || target.length === 0) continue;
145
+ cwd = path.isAbsolute(target) ? target : path.join(cwd, target);
146
+ }
147
+ return cwd;
148
+ },
149
+ };
150
+
151
+ /**
152
+ * Modifiers the git plugin registers under `cwd.git`. Exposed as an
153
+ * array so the plugin merger sees each entry distinctly; the runtime
154
+ * could today also accept a single modifier, but the array form
155
+ * leaves room for future git-specific cwd tweaks without changing
156
+ * the plugin manifest.
157
+ */
158
+ export const gitCwdExtensions: readonly Modifier<unknown>[] = [
159
+ gitCwdExtension as Modifier<unknown>,
160
+ ];