deepdebug-local-agent 1.0.23 → 1.2.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 (178) hide show
  1. package/.claude/context.md +76 -0
  2. package/.claude/skills/README.md +25 -0
  3. package/.claude/skills/tdd-debugging/SKILL.md +53 -0
  4. package/.claude/skills/tdd-debugging/references/anti-patterns.md +43 -0
  5. package/.claude/skills/tdd-debugging/references/command-reference.md +46 -0
  6. package/.claude/standards/README.md +51 -0
  7. package/.claude/standards/clean-code.md +75 -0
  8. package/.claude/standards/code-review-checklist.md +61 -0
  9. package/.claude/standards/layered-architecture.md +163 -0
  10. package/.claude/standards/node.md +45 -0
  11. package/.claude/standards/security-owasp.md +114 -0
  12. package/.claude/standards/simplicity.md +42 -0
  13. package/.claude/standards/solid.md +64 -0
  14. package/.claude/standards/testing.md +59 -0
  15. package/.cursor/rules/standards.mdc +17 -0
  16. package/.github/workflows/build-byoc-image.yml +315 -0
  17. package/.github/workflows/deploy-gcp.yml +102 -0
  18. package/.github/workflows/npm-publish.yml +76 -8
  19. package/AGENTS.md +21 -0
  20. package/CHANGELOG.md +76 -0
  21. package/CLAUDE.md +13 -0
  22. package/CLAUDE_CODE_AGENT_INFINITE.md +5 -3
  23. package/Dockerfile +4 -0
  24. package/Dockerfile.full +55 -3
  25. package/bin/install.js +16 -5
  26. package/cloudbuild-full.yaml +57 -0
  27. package/docker-compose.yml +5 -3
  28. package/docs/OBSERVABILITY.md +164 -0
  29. package/docs/byoc-agent-image.md +70 -0
  30. package/docs/dlp.md +78 -0
  31. package/docs/graphify-packaging-plan.md +133 -0
  32. package/docs/graphify-validation-run-2026-10-28.md +243 -0
  33. package/docs/releasing.md +37 -0
  34. package/docs/signed-commands.md +163 -0
  35. package/env.example +34 -0
  36. package/helm/Chart.yaml +2 -2
  37. package/helm/templates/deployment.yaml +13 -3
  38. package/helm/templates/networkpolicy.yaml +22 -0
  39. package/helm/templates/secret.yaml +1 -0
  40. package/helm/values.yaml +13 -14
  41. package/mcp-server.js +4 -1
  42. package/package.json +9 -4
  43. package/scripts/next-version.mjs +75 -0
  44. package/scripts/upgrade-observability-push.sh +227 -0
  45. package/src/ai-engine.js +22 -22
  46. package/src/config/trusted-signing-keys.json +3 -0
  47. package/src/detectors/build-tool-detector.js +50 -0
  48. package/src/detectors/multi-project-detector.js +22 -22
  49. package/src/detectors/service-detector.js +24 -24
  50. package/src/dlp/engine.js +111 -0
  51. package/src/dlp/index.js +137 -0
  52. package/src/dlp/rules.js +97 -0
  53. package/src/dlp/validators.js +123 -0
  54. package/src/exec-utils.js +26 -4
  55. package/src/git/base-git-provider.js +117 -117
  56. package/src/git/bitbucket-provider.js +2 -2
  57. package/src/git/git-provider-registry.js +18 -18
  58. package/src/git/github-provider.js +13 -13
  59. package/src/git/multi-repo-pr.js +161 -0
  60. package/src/mcp-http-server.js +32 -16
  61. package/src/observability/bundle-files.js +418 -0
  62. package/src/observability/detect.js +151 -0
  63. package/src/observability/provision.js +212 -0
  64. package/src/observability/render-cli.js +59 -0
  65. package/src/observability/routes.js +88 -0
  66. package/src/patch/patch-engine.js +28 -28
  67. package/src/patch-manager.js +85 -85
  68. package/src/patch_bkp.js +14 -14
  69. package/src/ports.js +4 -4
  70. package/src/routes/workspace.route.js +47 -16
  71. package/src/runtimes/base-runtime.js +75 -75
  72. package/src/runtimes/java/gradle-runtime.js +17 -17
  73. package/src/runtimes/java/java-integrations.js +25 -25
  74. package/src/runtimes/java/maven-runtime.js +34 -34
  75. package/src/runtimes/node/node-integrations.js +1 -1
  76. package/src/runtimes/node/npm-runtime.js +14 -14
  77. package/src/runtimes/node/yarn-runtime.js +4 -4
  78. package/src/runtimes/runtime-registry.js +25 -25
  79. package/src/server-local.js +4 -4
  80. package/src/server.js +1366 -333
  81. package/src/siblings/burst-suppressor.js +89 -0
  82. package/src/siblings/capabilities.js +273 -0
  83. package/src/siblings/env-compat.cjs +58 -0
  84. package/src/siblings/incident-forwarder.js +322 -0
  85. package/src/siblings/logger.js +15 -0
  86. package/src/siblings/metrics.js +94 -0
  87. package/src/utils/agent-auth.js +184 -0
  88. package/src/utils/approval-token.js +56 -0
  89. package/src/utils/bind-host.js +29 -0
  90. package/src/utils/branch-refspec.js +98 -0
  91. package/src/utils/child-env.js +34 -0
  92. package/src/utils/environment-diagnostics.js +55 -55
  93. package/src/utils/exec-policy.js +962 -0
  94. package/src/utils/exec-utils.js +15 -15
  95. package/src/utils/file-count.js +46 -0
  96. package/src/utils/fs-utils.js +10 -10
  97. package/src/utils/multi-repo-open.js +132 -0
  98. package/src/utils/open-gate.js +183 -0
  99. package/src/utils/ranged-read.js +65 -0
  100. package/src/utils/request-guards.js +77 -0
  101. package/src/utils/request-signing.js +342 -0
  102. package/src/utils/root-recovery.js +244 -0
  103. package/src/utils/safe-exec-args.js +58 -0
  104. package/src/utils/sandbox.js +182 -0
  105. package/src/utils/search-outcome.js +56 -0
  106. package/src/utils/workspace-root.js +80 -0
  107. package/src/validators/syntax-validator.js +149 -0
  108. package/src/workspace/detect-port.js +16 -16
  109. package/src/workspace/file-reader.js +1 -1
  110. package/src/workspace/process-manager.js +46 -46
  111. package/src/workspace-manager.js +86 -24
  112. package/test/adapters/registry.test.js +9 -2
  113. package/test/agent-auth.test.js +194 -0
  114. package/test/bind-host.test.js +26 -0
  115. package/test/branch-refspec.test.js +224 -0
  116. package/test/burst-suppressor.test.js +60 -0
  117. package/test/capabilities-access.test.js +47 -0
  118. package/test/capabilities.test.js +87 -0
  119. package/test/child-env.test.js +23 -0
  120. package/test/dlp/engine.test.js +173 -0
  121. package/test/dlp/fixtures.js +126 -0
  122. package/test/dlp/index.test.js +166 -0
  123. package/test/env-compat.test.js +71 -0
  124. package/test/exec-policy.test.js +463 -0
  125. package/test/file-count.test.js +64 -0
  126. package/test/incident-forwarder.test.js +231 -0
  127. package/test/multi-repo-open.test.js +180 -0
  128. package/test/multi-repo-pr.test.js +129 -0
  129. package/test/next-version.test.js +48 -0
  130. package/test/observability-detect.test.js +199 -0
  131. package/test/open-gate.test.js +341 -0
  132. package/test/ranged-read.test.js +60 -0
  133. package/test/request-guards.test.js +91 -0
  134. package/test/request-signing.test.js +284 -0
  135. package/test/root-recovery-sidecar.test.js +171 -0
  136. package/test/root-recovery.test.js +274 -0
  137. package/test/safe-exec-args.test.js +58 -0
  138. package/test/sandbox.test.js +98 -0
  139. package/test/search-outcome.test.js +50 -0
  140. package/test/workspace-root.test.js +64 -0
  141. package/tools/graphify-validation/README.md +92 -0
  142. package/tools/graphify-validation/config/questions.json +31 -0
  143. package/tools/graphify-validation/config/repos.json +9 -0
  144. package/tools/graphify-validation/config/symbols.json +6 -0
  145. package/tools/graphify-validation/fixtures/spring/expected.json +34 -0
  146. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AppConfig.java +29 -0
  147. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AuditLogger.java +15 -0
  148. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/Cache.java +7 -0
  149. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/CheckoutService.java +25 -0
  150. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/InMemoryCache.java +18 -0
  151. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/OrderController.java +22 -0
  152. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaymentGateway.java +5 -0
  153. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaypalGateway.java +11 -0
  154. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/RefundService.java +14 -0
  155. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/StripeGateway.java +11 -0
  156. package/tools/graphify-validation/lib/affected_vs_rg.py +81 -0
  157. package/tools/graphify-validation/lib/audit_hook/sitecustomize.py +22 -0
  158. package/tools/graphify-validation/lib/graph_metrics.py +96 -0
  159. package/tools/graphify-validation/lib/gv_common.py +130 -0
  160. package/tools/graphify-validation/lib/licenses.py +47 -0
  161. package/tools/graphify-validation/lib/pip_audit_summary.py +18 -0
  162. package/tools/graphify-validation/lib/recall.py +115 -0
  163. package/tools/graphify-validation/lib/report.py +226 -0
  164. package/tools/graphify-validation/lib/run_all.py +237 -0
  165. package/tools/graphify-validation/lib/run_graphify.py +144 -0
  166. package/tools/graphify-validation/lib/rusage_helper.py +17 -0
  167. package/tools/graphify-validation/lib/sample_edges.py +246 -0
  168. package/tools/graphify-validation/lib/spring_fixture.py +81 -0
  169. package/tools/graphify-validation/requirements-py311.lock +508 -0
  170. package/tools/graphify-validation/requirements-tools.in +1 -0
  171. package/tools/graphify-validation/requirements-tools.lock +371 -0
  172. package/tools/graphify-validation/requirements.in +1 -0
  173. package/tools/graphify-validation/requirements.lock +502 -0
  174. package/tools/graphify-validation/run.sh +43 -0
  175. package/tools/graphify-validation/tests/test_harness.py +151 -0
  176. package/cloudbuild-agent-qa.yaml +0 -43
  177. package/cloudbuild-agent-staging.yaml +0 -40
  178. package/cloudbuild.yaml.deprecated +0 -42
@@ -0,0 +1,231 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { IncidentForwarder } from '../src/siblings/incident-forwarder.js';
4
+
5
+ const NOOP_LOGGER = { info() {}, warn() {}, error() {}, debug() {} };
6
+ // Disable burst suppression where a test intentionally sends identical events.
7
+ const NO_SUPPRESS = { shouldSuppress: () => false };
8
+
9
+ function validEvent(over = {}) {
10
+ return {
11
+ adapter: 'sap',
12
+ errorCode: 'DUMP',
13
+ file: '/logs/dev_w0',
14
+ message: 'ABAP runtime error',
15
+ source: 'sap-nw-01',
16
+ timestamp: '2026-07-28T10:00:00Z',
17
+ ...over,
18
+ };
19
+ }
20
+
21
+ test('forwards a valid 6-key event to the gateway', async () => {
22
+ const calls = [];
23
+ const fetchImpl = async (url, opts) => {
24
+ calls.push({ url, body: JSON.parse(opts.body) });
25
+ return { ok: true, status: 200 };
26
+ };
27
+ const fwd = new IncidentForwarder({ logger: NOOP_LOGGER, maxRetries: 0 });
28
+ fwd.configure({ gatewayUrl: 'https://gw.example', tenantId: 't1', apiKey: 'k', fetchImpl });
29
+
30
+ fwd.handleAdapterEvent(validEvent(), 'SapAdapter');
31
+ await fwd.flush();
32
+
33
+ assert.equal(fwd.stats.forwarded, 1);
34
+ assert.equal(fwd.stats.failed, 0);
35
+ assert.equal(calls.length, 1);
36
+ assert.equal(calls[0].url, 'https://gw.example/api/v1/internal/incident/create');
37
+ assert.equal(calls[0].body.service, 'sap-nw-01');
38
+ assert.equal(calls[0].body.detectedBy, 'local-agent-adapter');
39
+ });
40
+
41
+ test('rejects a malformed event (missing key) without forwarding', async () => {
42
+ let fetched = false;
43
+ const fetchImpl = async () => { fetched = true; return { ok: true, status: 200 }; };
44
+ const fwd = new IncidentForwarder({ logger: NOOP_LOGGER, maxRetries: 0 });
45
+ fwd.configure({ gatewayUrl: 'https://gw.example', fetchImpl });
46
+
47
+ const bad = validEvent();
48
+ delete bad.source; // drop a required key
49
+ const outcome = fwd.handleAdapterEvent(bad, 'SapAdapter');
50
+ await fwd.flush();
51
+
52
+ assert.equal(outcome, 'malformed');
53
+ assert.equal(fetched, false);
54
+ assert.equal(fwd.stats.malformed, 1);
55
+ assert.equal(fwd.stats.forwarded, 0);
56
+ });
57
+
58
+ test('empty message is treated as malformed', async () => {
59
+ const fwd = new IncidentForwarder({ logger: NOOP_LOGGER, maxRetries: 0 });
60
+ fwd.configure({ gatewayUrl: 'https://gw.example', fetchImpl: async () => ({ ok: true, status: 200 }) });
61
+ fwd.handleAdapterEvent(validEvent({ message: ' ' }), 'SapAdapter');
62
+ await fwd.flush();
63
+ assert.equal(fwd.stats.malformed, 1);
64
+ assert.equal(fwd.stats.forwarded, 0);
65
+ });
66
+
67
+ test('gateway unreachable is retried then counted, never thrown', async () => {
68
+ let attempts = 0;
69
+ const fetchImpl = async () => { attempts++; throw new Error('ECONNREFUSED'); };
70
+ const fwd = new IncidentForwarder({ logger: NOOP_LOGGER, maxRetries: 2 });
71
+ fwd.configure({ gatewayUrl: 'https://gw.example', fetchImpl });
72
+
73
+ assert.doesNotThrow(() => fwd.handleAdapterEvent(validEvent(), 'SapAdapter'));
74
+ await fwd.flush();
75
+
76
+ assert.equal(attempts, 3); // 1 + 2 retries
77
+ assert.equal(fwd.stats.failed, 1);
78
+ assert.equal(fwd.stats.forwarded, 0);
79
+ });
80
+
81
+ test('non-2xx response is a failure', async () => {
82
+ const fwd = new IncidentForwarder({ logger: NOOP_LOGGER, maxRetries: 0 });
83
+ fwd.configure({ gatewayUrl: 'https://gw.example', fetchImpl: async () => ({ ok: false, status: 503 }) });
84
+ fwd.handleAdapterEvent(validEvent(), 'SapAdapter');
85
+ await fwd.flush();
86
+ assert.equal(fwd.stats.failed, 1);
87
+ assert.equal(fwd.stats.forwarded, 0);
88
+ });
89
+
90
+ test('events received before configure() are buffered, not lost', async () => {
91
+ const calls = [];
92
+ const fetchImpl = async () => { calls.push(1); return { ok: true, status: 200 }; };
93
+ const fwd = new IncidentForwarder({ logger: NOOP_LOGGER, maxRetries: 0, suppressor: NO_SUPPRESS });
94
+
95
+ // Not configured yet -> should buffer.
96
+ fwd.handleAdapterEvent(validEvent(), 'SapAdapter');
97
+ fwd.handleAdapterEvent(validEvent(), 'SapAdapter');
98
+ assert.equal(calls.length, 0);
99
+ assert.equal(fwd.queue.length, 2);
100
+
101
+ fwd.configure({ gatewayUrl: 'https://gw.example', fetchImpl });
102
+ await fwd.flush();
103
+ assert.equal(fwd.stats.forwarded, 2);
104
+ });
105
+
106
+ test('contract error (401) is NOT retried and is recorded loudly', async () => {
107
+ let calls = 0;
108
+ const fetchImpl = async () => {
109
+ calls++;
110
+ return { ok: false, status: 401, text: async () => 'unauthorized' };
111
+ };
112
+ const fwd = new IncidentForwarder({ logger: NOOP_LOGGER, maxRetries: 3 });
113
+ fwd.configure({ gatewayUrl: 'https://gw.example', fetchImpl });
114
+ fwd.handleAdapterEvent(validEvent(), 'SapAdapter');
115
+ await fwd.flush();
116
+
117
+ assert.equal(calls, 1); // no retries on a contract error
118
+ assert.equal(fwd.stats.contract, 1);
119
+ assert.equal(fwd.stats.forwarded, 0);
120
+ assert.ok(fwd.contractError);
121
+ assert.equal(fwd.contractError.status, 401);
122
+ assert.equal(fwd.contractError.body, 'unauthorized');
123
+ assert.equal(fwd.getStatus().contract_ok, false);
124
+ });
125
+
126
+ test('403 and 404 are also contract errors (no retry)', async () => {
127
+ for (const status of [403, 404]) {
128
+ let calls = 0;
129
+ const fwd = new IncidentForwarder({ logger: NOOP_LOGGER, maxRetries: 5 });
130
+ fwd.configure({ gatewayUrl: 'https://gw.example', fetchImpl: async () => { calls++; return { ok: false, status, text: async () => '' }; } });
131
+ fwd.handleAdapterEvent(validEvent(), 'a');
132
+ await fwd.flush();
133
+ assert.equal(calls, 1, `status ${status} must not retry`);
134
+ assert.equal(fwd.stats.contract, 1);
135
+ }
136
+ });
137
+
138
+ test('5xx is transport-class and IS retried', async () => {
139
+ let calls = 0;
140
+ const fetchImpl = async () => { calls++; return { ok: false, status: 503, text: async () => 'busy' }; };
141
+ const fwd = new IncidentForwarder({ logger: NOOP_LOGGER, maxRetries: 2 });
142
+ fwd.configure({ gatewayUrl: 'https://gw.example', fetchImpl });
143
+ fwd.handleAdapterEvent(validEvent(), 'a');
144
+ await fwd.flush();
145
+ assert.equal(calls, 3); // 1 + 2 retries
146
+ assert.equal(fwd.stats.contract, 0);
147
+ assert.equal(fwd.stats.failed, 1);
148
+ });
149
+
150
+ test('429 is rate limiting -> TRANSPORT class, keeps retry/backoff', async () => {
151
+ let calls = 0;
152
+ const fetchImpl = async () => { calls++; return { ok: false, status: 429, text: async () => 'slow down' }; };
153
+ const fwd = new IncidentForwarder({ logger: NOOP_LOGGER, maxRetries: 2 });
154
+ fwd.configure({ gatewayUrl: 'https://gw.example', fetchImpl });
155
+ fwd.handleAdapterEvent(validEvent(), 'a');
156
+ await fwd.flush();
157
+ assert.equal(calls, 3); // retried, not treated as a contract error
158
+ assert.equal(fwd.stats.contract, 0);
159
+ assert.equal(fwd.contractError, null);
160
+ });
161
+
162
+ test('failure metric reason is class-based (contract vs transport) with a code', async () => {
163
+ const seen = [];
164
+ const metrics = {
165
+ incReceived() {}, incForwarded() {}, incDropped() {},
166
+ incFailure: (reason, code) => seen.push({ reason, code }),
167
+ };
168
+ // contract
169
+ const c = new IncidentForwarder({ logger: NOOP_LOGGER, maxRetries: 3, metrics });
170
+ c.configure({ gatewayUrl: 'https://gw.example', fetchImpl: async () => ({ ok: false, status: 401, text: async () => '' }) });
171
+ c.handleAdapterEvent(validEvent(), 'a'); await c.flush();
172
+ // transport
173
+ const t = new IncidentForwarder({ logger: NOOP_LOGGER, maxRetries: 0, metrics });
174
+ t.configure({ gatewayUrl: 'https://gw.example', fetchImpl: async () => { throw new Error('down'); } });
175
+ t.handleAdapterEvent(validEvent(), 'a'); await t.flush();
176
+
177
+ assert.deepEqual(seen.find((s) => s.reason === 'contract'), { reason: 'contract', code: '401' });
178
+ assert.ok(seen.some((s) => s.reason === 'transport'));
179
+ });
180
+
181
+ test('contract preflight maps status -> verdict without creating data', async () => {
182
+ const cases = [
183
+ [400, true, 'reachable_auth_ok'],
184
+ [401, false, 'auth_rejected'],
185
+ [404, false, 'path_not_found'],
186
+ [503, false, 'transport_or_5xx'],
187
+ ];
188
+ for (const [status, ok, reason] of cases) {
189
+ const sent = [];
190
+ const fwd = new IncidentForwarder({ logger: NOOP_LOGGER });
191
+ fwd.configure({ gatewayUrl: 'https://gw.example', fetchImpl: async (_u, o) => { sent.push(JSON.parse(o.body)); return { ok: status < 400, status, text: async () => '' }; } });
192
+ const r = await fwd.contractPreflight();
193
+ assert.equal(r.ok, ok, `status ${status}`);
194
+ assert.equal(r.reason, reason);
195
+ // The probe must NOT carry a tenantId (so it can never create an incident).
196
+ assert.equal(sent[0].tenantId, undefined);
197
+ }
198
+ });
199
+
200
+ test('contract preflight when unreachable is not-ok and never throws', async () => {
201
+ const fwd = new IncidentForwarder({ logger: NOOP_LOGGER });
202
+ fwd.configure({ gatewayUrl: 'https://gw.example', fetchImpl: async () => { throw new Error('ECONNREFUSED'); } });
203
+ const r = await fwd.contractPreflight();
204
+ assert.equal(r.ok, false);
205
+ assert.equal(r.reason, 'unreachable');
206
+ });
207
+
208
+ test('backpressure evicts oldest and counts the drop (no silent drop)', async () => {
209
+ // Gate fetch so in-flight stays busy and the queue fills past maxQueue.
210
+ let release;
211
+ const gate = new Promise((r) => { release = r; });
212
+ const fetchImpl = async () => { await gate; return { ok: true, status: 200 }; };
213
+ const fwd = new IncidentForwarder({
214
+ logger: NOOP_LOGGER, maxRetries: 0, maxInflight: 1, maxQueue: 2, suppressor: NO_SUPPRESS,
215
+ });
216
+ fwd.configure({ gatewayUrl: 'https://gw.example', fetchImpl });
217
+
218
+ fwd.handleAdapterEvent(validEvent(), 'a'); // -> in-flight (gated)
219
+ fwd.handleAdapterEvent(validEvent(), 'a'); // -> queue [1]
220
+ fwd.handleAdapterEvent(validEvent(), 'a'); // -> queue [1,2] (== maxQueue)
221
+ const outcome = fwd.handleAdapterEvent(validEvent(), 'a'); // -> evict oldest
222
+
223
+ assert.equal(outcome, 'dropped');
224
+ assert.equal(fwd.stats.dropped, 1);
225
+
226
+ release();
227
+ await fwd.flush();
228
+ // 1 in-flight + 2 remaining in queue were forwarded; 1 was dropped.
229
+ assert.equal(fwd.stats.forwarded, 3);
230
+ assert.equal(fwd.stats.dropped, 1);
231
+ });
@@ -0,0 +1,180 @@
1
+ // test/multi-repo-open.test.js
2
+ // MULTI-REPO OPEN partial tolerance (QA 2026-09-22): POST /workspace/open
3
+ // with repos[] used ONE try/catch around a sequential clone loop, so the
4
+ // first failed clone ("https://github.com/williambella/visionhammer-vault.git
5
+ // -> Repository not found") aborted the loop and answered 500, discarding the
6
+ // 9 repos already cloned. The hydration is now per-repo tolerant: >=1 cloned
7
+ // -> 200 {partial:true, failed:[...masked]}; ALL failed -> 500;
8
+ // MULTI_REPO_ALLOW_PARTIAL=false -> first failure aborts (old behavior).
9
+
10
+ import { test, describe } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import path from 'node:path';
13
+ import {
14
+ hydrateRepos,
15
+ multiRepoAllowPartial,
16
+ planRepos,
17
+ repoDirName,
18
+ } from '../src/utils/multi-repo-open.js';
19
+ import { confineToRoot } from '../src/utils/sandbox.js';
20
+
21
+ const WS = '/tmp/workspaces/ws-multi-qa';
22
+ const TOKEN = 'ghp_SECRET_TOKEN_123';
23
+ const BAD_REPO = 'https://github.com/williambella/visionhammer-vault.git';
24
+ const GOOD_REPOS = [
25
+ 'https://github.com/VISIONHAMMER/deepdebug-gateway.git',
26
+ 'https://github.com/VISIONHAMMER/visionhammer-agents.git',
27
+ 'https://github.com/VISIONHAMMER/deepdebug-local-agent.git',
28
+ ];
29
+
30
+ const maskUrlCreds = (s) => String(s).replace(/(https?:\/\/)[^\s@]+@/gi, '$1***@');
31
+ const quiet = { log() {}, warn() {} };
32
+
33
+ /** Fake git: fails for repo URLs in `failing` with the QA-shaped message. */
34
+ function fakeCloneRepo(failing) {
35
+ const calls = [];
36
+ const cloneRepo = async ({ repoUrl, token, destDir }) => {
37
+ calls.push({ repoUrl, destDir });
38
+ if (failing.has(repoUrl)) {
39
+ const authed = repoUrl.replace('https://', `https://x-access-token:${token}@`);
40
+ throw new Error(`Command failed: git clone --branch main --single-branch --depth 50 "${authed}" "${destDir}"\nremote: Repository not found.\nfatal: repository '${authed}' not found`);
41
+ }
42
+ };
43
+ return { cloneRepo, calls };
44
+ }
45
+
46
+ function deps(overrides = {}) {
47
+ return {
48
+ detectProject: async () => ({ language: 'java', buildTool: 'maven' }),
49
+ mkdir: async () => {},
50
+ maskUrlCreds,
51
+ token: TOKEN,
52
+ branch: 'main',
53
+ allowPartial: true,
54
+ wsId: 'ws-multi-qa',
55
+ logger: quiet,
56
+ ...overrides,
57
+ };
58
+ }
59
+
60
+ describe('multiRepoAllowPartial (env flag)', () => {
61
+ test('defaults ON; only the literal "false" turns it off', () => {
62
+ assert.equal(multiRepoAllowPartial({}), true);
63
+ assert.equal(multiRepoAllowPartial({ MULTI_REPO_ALLOW_PARTIAL: 'true' }), true);
64
+ assert.equal(multiRepoAllowPartial({ MULTI_REPO_ALLOW_PARTIAL: '' }), true);
65
+ assert.equal(multiRepoAllowPartial({ MULTI_REPO_ALLOW_PARTIAL: 'false' }), false);
66
+ assert.equal(multiRepoAllowPartial({ MULTI_REPO_ALLOW_PARTIAL: ' FALSE ' }), false);
67
+ });
68
+ });
69
+
70
+ describe('planRepos (validation before any clone)', () => {
71
+ test('resolves per-repo dirs inside the workspace dir', () => {
72
+ const plan = planRepos(
73
+ [{ repoUrl: GOOD_REPOS[0] }, { repoUrl: GOOD_REPOS[1], dir: 'agents' }],
74
+ { workspaceDir: WS, confineToRoot },
75
+ );
76
+ assert.deepEqual(plan.map((p) => p.dir), ['VISIONHAMMER_deepdebug-gateway', 'agents']);
77
+ // confineToRoot realpaths the base (macOS: /tmp -> /private/tmp); compare the tail
78
+ assert.ok(plan[1].destDir.endsWith(path.join('ws-multi-qa', 'agents')), plan[1].destDir);
79
+ });
80
+
81
+ test('missing repoUrl / escaping dir -> tagged 400, nothing planned', () => {
82
+ assert.throws(() => planRepos([{ repoUrl: GOOD_REPOS[0] }, {}], { workspaceDir: WS, confineToRoot }),
83
+ (e) => e.code === 'BAD_REQUEST' && e.httpStatus === 400);
84
+ assert.throws(() => planRepos([{ repoUrl: GOOD_REPOS[0], dir: '../escape' }], { workspaceDir: WS, confineToRoot: () => { throw new Error('escape'); } }),
85
+ (e) => e.code === 'BAD_REQUEST' && /invalid repo dir/.test(e.message));
86
+ });
87
+
88
+ test('repoDirName: org_repo, .git stripped', () => {
89
+ assert.equal(repoDirName(BAD_REPO), 'williambella_visionhammer-vault');
90
+ });
91
+ });
92
+
93
+ describe('hydrateRepos - partial tolerance', () => {
94
+ test('QA failure mode: ONE bad repo among good ones -> the good ones survive, failed[] is masked', async () => {
95
+ const repos = [...GOOD_REPOS.slice(0, 2), BAD_REPO, GOOD_REPOS[2]].map((repoUrl) => ({ repoUrl }));
96
+ const plan = planRepos(repos, { workspaceDir: WS, confineToRoot });
97
+ const { cloneRepo, calls } = fakeCloneRepo(new Set([BAD_REPO]));
98
+
99
+ const { cloned, failed, firstError } = await hydrateRepos(plan, deps({ cloneRepo }));
100
+
101
+ assert.equal(calls.length, 4, 'the loop must NOT stop at the failing repo');
102
+ assert.equal(cloned.length, 3);
103
+ assert.equal(failed.length, 1);
104
+ assert.equal(failed[0].dir, 'williambella_visionhammer-vault');
105
+ assert.equal(failed[0].repoUrl, BAD_REPO);
106
+ assert.match(failed[0].error, /Repository not found/);
107
+ // SEG-1: the token never leaks through the response/log shape
108
+ assert.doesNotMatch(JSON.stringify({ cloned, failed }), new RegExp(TOKEN));
109
+ assert.match(failed[0].error, /x-access-token:\*\*\*@|\/\/\*\*\*@/);
110
+ assert.ok(firstError instanceof Error);
111
+ // the server maps this to 200 {ok:true, partial:true}
112
+ assert.equal(cloned.length > 0 && failed.length > 0, true);
113
+ });
114
+
115
+ test('ALL repos bad -> nothing cloned, every failure listed (server answers 500)', async () => {
116
+ const plan = planRepos([{ repoUrl: BAD_REPO }, { repoUrl: GOOD_REPOS[0] }], { workspaceDir: WS, confineToRoot });
117
+ const { cloneRepo } = fakeCloneRepo(new Set([BAD_REPO, GOOD_REPOS[0]]));
118
+
119
+ const { cloned, failed, firstError } = await hydrateRepos(plan, deps({ cloneRepo }));
120
+
121
+ assert.equal(cloned.length, 0);
122
+ assert.equal(failed.length, 2);
123
+ assert.match(firstError.message, /visionhammer-vault/);
124
+ });
125
+
126
+ test('MULTI_REPO_ALLOW_PARTIAL=false -> first failure aborts the loop (pre-fix behavior)', async () => {
127
+ const repos = [GOOD_REPOS[0], BAD_REPO, GOOD_REPOS[1]].map((repoUrl) => ({ repoUrl }));
128
+ const plan = planRepos(repos, { workspaceDir: WS, confineToRoot });
129
+ const { cloneRepo, calls } = fakeCloneRepo(new Set([BAD_REPO]));
130
+
131
+ await assert.rejects(
132
+ hydrateRepos(plan, deps({ cloneRepo, allowPartial: multiRepoAllowPartial({ MULTI_REPO_ALLOW_PARTIAL: 'false' }) })),
133
+ /Repository not found/,
134
+ );
135
+ assert.equal(calls.length, 2, 'third repo never attempted');
136
+ });
137
+
138
+ test('all good -> partial:false shape (failed empty, no firstError)', async () => {
139
+ const plan = planRepos(GOOD_REPOS.map((repoUrl) => ({ repoUrl })), { workspaceDir: WS, confineToRoot });
140
+ const { cloneRepo } = fakeCloneRepo(new Set());
141
+ const { cloned, failed, firstError } = await hydrateRepos(plan, deps({ cloneRepo }));
142
+ assert.equal(cloned.length, 3);
143
+ assert.deepEqual(failed, []);
144
+ assert.equal(firstError, null);
145
+ assert.deepEqual(cloned.map((c) => c.repoUrl), GOOD_REPOS);
146
+ });
147
+
148
+ test('per-repo token/branch override the workspace-level ones; tagged git errors keep their code', async () => {
149
+ const seen = [];
150
+ const cloneRepo = async (args) => {
151
+ seen.push(args);
152
+ if (args.branch === 'nope') { const e = new Error('branch nope not found'); e.code = 'BRANCH_NOT_FOUND'; e.httpStatus = 404; throw e; }
153
+ };
154
+ const plan = planRepos([
155
+ { repoUrl: GOOD_REPOS[0], token: 'per-repo-token', branch: 'develop' },
156
+ { repoUrl: GOOD_REPOS[1], branch: 'nope' },
157
+ ], { workspaceDir: WS, confineToRoot });
158
+ const { cloned, failed, firstError } = await hydrateRepos(plan, deps({ cloneRepo }));
159
+ assert.equal(seen[0].token, 'per-repo-token');
160
+ assert.equal(seen[0].branch, 'develop');
161
+ assert.equal(seen[1].token, TOKEN);
162
+ assert.equal(cloned.length, 1);
163
+ assert.equal(failed.length, 1);
164
+ assert.equal(firstError.code, 'BRANCH_NOT_FOUND');
165
+ });
166
+
167
+ test('the partial log line names counts and masked reasons', async () => {
168
+ const lines = [];
169
+ const logger = { log() {}, warn: (m) => lines.push(m) };
170
+ const plan = planRepos([{ repoUrl: GOOD_REPOS[0] }, { repoUrl: BAD_REPO }], { workspaceDir: WS, confineToRoot });
171
+ const { cloneRepo } = fakeCloneRepo(new Set([BAD_REPO]));
172
+ const cleaned = [];
173
+ await hydrateRepos(plan, deps({ cloneRepo, logger, cleanupFailed: async (d) => { cleaned.push(d); } }));
174
+ const partial = lines.find((l) => l.startsWith('[multi-repo] partial:'));
175
+ assert.match(partial, /1 cloned, 1 failed/);
176
+ assert.deepEqual(cleaned, [path.join(plan[1].destDir)], 'failed per-repo dir is cleaned up');
177
+ assert.match(partial, /williambella_visionhammer-vault \(.*Repository not found/s);
178
+ assert.doesNotMatch(lines.join('\n'), new RegExp(TOKEN));
179
+ });
180
+ });
@@ -0,0 +1,129 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "fs";
4
+ import os from "os";
5
+ import path from "path";
6
+
7
+ import {
8
+ multiRepoPrEnabled,
9
+ listRepoSubdirs,
10
+ isMultiRepoRoot,
11
+ parseAuthedRemote,
12
+ stripRemoteCreds,
13
+ buildCompareUrl,
14
+ buildCrossLinkNote,
15
+ buildMultiRepoPrBody,
16
+ } from "../src/git/multi-repo-pr.js";
17
+
18
+ function mkTmp() {
19
+ return fs.mkdtempSync(path.join(os.tmpdir(), "mrpr-"));
20
+ }
21
+ function mkRepo(root, name) {
22
+ const p = path.join(root, name);
23
+ fs.mkdirSync(path.join(p, ".git"), { recursive: true });
24
+ return p;
25
+ }
26
+
27
+ test("multiRepoPrEnabled defaults on, false disables", () => {
28
+ assert.equal(multiRepoPrEnabled({}), true);
29
+ assert.equal(multiRepoPrEnabled({ MULTI_REPO_PR_ENABLED: "true" }), true);
30
+ assert.equal(multiRepoPrEnabled({ MULTI_REPO_PR_ENABLED: "false" }), false);
31
+ assert.equal(multiRepoPrEnabled({ MULTI_REPO_PR_ENABLED: "FALSE" }), false);
32
+ });
33
+
34
+ test("listRepoSubdirs finds only git sub-dirs, sorted", () => {
35
+ const root = mkTmp();
36
+ mkRepo(root, "beta");
37
+ mkRepo(root, "alpha");
38
+ fs.mkdirSync(path.join(root, "not-a-repo"), { recursive: true });
39
+ const dirs = listRepoSubdirs(root).map((r) => r.dir);
40
+ assert.deepEqual(dirs, ["alpha", "beta"]);
41
+ });
42
+
43
+ test("isMultiRepoRoot: parent with git sub-repos = true", () => {
44
+ const root = mkTmp();
45
+ mkRepo(root, "svc-a");
46
+ mkRepo(root, "svc-b");
47
+ assert.equal(isMultiRepoRoot(root), true);
48
+ });
49
+
50
+ test("isMultiRepoRoot: single-repo root (root itself a git repo) = false", () => {
51
+ const root = mkTmp();
52
+ fs.mkdirSync(path.join(root, ".git"), { recursive: true });
53
+ mkRepo(root, "nested"); // even with a nested repo, root being a repo wins
54
+ assert.equal(isMultiRepoRoot(root), false);
55
+ });
56
+
57
+ test("isMultiRepoRoot: empty/plain dir = false", () => {
58
+ const root = mkTmp();
59
+ fs.mkdirSync(path.join(root, "plain"), { recursive: true });
60
+ assert.equal(isMultiRepoRoot(root), false);
61
+ });
62
+
63
+ test("parseAuthedRemote: GitHub x-access-token", () => {
64
+ const r = parseAuthedRemote("https://x-access-token:ghp_ABC@github.com/acme/web.git");
65
+ assert.equal(r.provider, "github");
66
+ assert.equal(r.host, "github.com");
67
+ assert.equal(r.ownerRepo, "acme/web");
68
+ assert.equal(r.apiToken, "ghp_ABC");
69
+ });
70
+
71
+ test("parseAuthedRemote: GitLab oauth2 + no .git suffix", () => {
72
+ const r = parseAuthedRemote("https://oauth2:glpat_X@gitlab.com/group/sub/repo");
73
+ assert.equal(r.provider, "gitlab");
74
+ assert.equal(r.ownerRepo, "group/sub/repo");
75
+ assert.equal(r.apiToken, "glpat_X");
76
+ });
77
+
78
+ test("parseAuthedRemote: no credentials", () => {
79
+ const r = parseAuthedRemote("https://github.com/acme/web");
80
+ assert.equal(r.provider, "github");
81
+ assert.equal(r.ownerRepo, "acme/web");
82
+ assert.equal(r.apiToken, "");
83
+ });
84
+
85
+ test("stripRemoteCreds removes userinfo and .git", () => {
86
+ assert.equal(
87
+ stripRemoteCreds("https://x-access-token:TOK@github.com/acme/web.git"),
88
+ "https://github.com/acme/web"
89
+ );
90
+ });
91
+
92
+ test("buildCompareUrl per provider", () => {
93
+ assert.match(buildCompareUrl("https://github.com/a/b", "github", "vh/x", "main"),
94
+ /\/compare\/main\.\.\.vh\/x\?expand=1$/);
95
+ assert.match(buildCompareUrl("https://gitlab.com/a/b", "gitlab", "vh/x", "main"),
96
+ /merge_requests\/new/);
97
+ assert.match(buildCompareUrl("https://bitbucket.org/a/b", "bitbucket", "vh/x", "main"),
98
+ /pull-requests\/new/);
99
+ });
100
+
101
+ test("buildCrossLinkNote links only siblings", () => {
102
+ const prs = [
103
+ { dir: "web", prUrl: "https://github.com/a/web/pull/1" },
104
+ { dir: "api", prUrl: "https://github.com/a/api/pull/2" },
105
+ { dir: "libs", prUrl: "" }, // no url -> excluded
106
+ ];
107
+ const note = buildCrossLinkNote("web", prs);
108
+ assert.ok(note.includes("api: https://github.com/a/api/pull/2"));
109
+ assert.ok(!note.includes("web/pull/1")); // not self
110
+ assert.ok(!note.includes("libs:"));
111
+ });
112
+
113
+ test("buildCrossLinkNote: no siblings -> empty", () => {
114
+ assert.equal(buildCrossLinkNote("only", [{ dir: "only", prUrl: "u" }]), "");
115
+ });
116
+
117
+ test("buildMultiRepoPrBody is ASCII and carries dir/branch + trailer", () => {
118
+ const body = buildMultiRepoPrBody({
119
+ dir: "web", branch: "vh/abc123", commitMessage: "feat: x",
120
+ filesChanged: 3, commitSha: "deadbeefcafe",
121
+ });
122
+ assert.ok(body.includes("`web`"));
123
+ assert.ok(body.includes("vh/abc123"));
124
+ assert.ok(body.includes("deadbeef"));
125
+ assert.ok(body.includes("Generated by VisionHammer Agent"));
126
+ assert.ok(!/claude/i.test(body));
127
+ // ASCII-only guard
128
+ assert.ok(/^[\x00-\x7F]*$/.test(body), "PR body must be ASCII-only");
129
+ });
@@ -0,0 +1,48 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { classify, bumpFor, nextVersion } from '../scripts/next-version.mjs'
4
+
5
+ test('fix and other non-feature types are a patch', () => {
6
+ for (const s of ['fix: handle empty body', 'fix(ci): x', 'chore: tidy', 'docs: readme', 'ci: workflow', 'perf: faster', 'refactor: split', 'Update thing']) {
7
+ assert.equal(classify(s), 'patch', s)
8
+ }
9
+ })
10
+
11
+ test('feat is a minor', () => {
12
+ assert.equal(classify('feat: add DLP masking'), 'minor')
13
+ assert.equal(classify('feat(ws): metadata only channel'), 'minor')
14
+ })
15
+
16
+ test('breaking changes are a major, by "!" or by footer', () => {
17
+ assert.equal(classify('feat!: drop node 18'), 'major')
18
+ assert.equal(classify('fix(api)!: rename endpoint'), 'major')
19
+ assert.equal(classify('fix: x\n\nBREAKING CHANGE: signature enforce is mandatory'), 'major')
20
+ assert.equal(classify('chore: y\n\nBREAKING-CHANGE: z'), 'major')
21
+ })
22
+
23
+ test('auto-bump and merge commits are ignored', () => {
24
+ assert.equal(classify('ci: auto bump to 1.1.0 [skip ci]'), null)
25
+ assert.equal(classify('Merge pull request #55 from x/y'), null)
26
+ assert.equal(classify(''), null)
27
+ })
28
+
29
+ test('the highest level wins', () => {
30
+ assert.equal(bumpFor(['fix: a', 'fix: b']), 'patch')
31
+ assert.equal(bumpFor(['fix: a', 'feat: b', 'chore: c']), 'minor')
32
+ assert.equal(bumpFor(['feat: a', 'fix!: b', 'fix: c']), 'major')
33
+ assert.equal(bumpFor([]), 'patch')
34
+ assert.equal(bumpFor(['ci: auto bump to 1.2.0 [skip ci]']), 'patch')
35
+ })
36
+
37
+ test('a footer that is not at the start of a line is not breaking', () => {
38
+ assert.equal(classify('fix: mention BREAKING CHANGE: in prose'), 'patch')
39
+ })
40
+
41
+ test('nextVersion resets the lower levels', () => {
42
+ assert.equal(nextVersion('1.1.0', 'patch'), '1.1.1')
43
+ assert.equal(nextVersion('1.1.7', 'minor'), '1.2.0')
44
+ assert.equal(nextVersion('1.9.4', 'major'), '2.0.0')
45
+ assert.equal(nextVersion('0.0.1', 'minor'), '0.1.0')
46
+ assert.throws(() => nextVersion('1.1', 'patch'))
47
+ assert.throws(() => nextVersion('1.x.0', 'patch'))
48
+ })