edsger 0.42.1 → 0.44.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 (98) hide show
  1. package/.claude/settings.local.json +23 -3
  2. package/.env.local +12 -0
  3. package/dist/api/release-test-cases.d.ts +7 -0
  4. package/dist/api/release-test-cases.js +21 -0
  5. package/dist/api/releases.d.ts +41 -0
  6. package/dist/api/releases.js +31 -0
  7. package/dist/api/web-deploy.d.ts +8 -1
  8. package/dist/api/web-deploy.js +2 -1
  9. package/dist/commands/release-sync/index.d.ts +5 -0
  10. package/dist/commands/release-sync/index.js +38 -0
  11. package/dist/commands/smoke-test/index.d.ts +5 -0
  12. package/dist/commands/smoke-test/index.js +40 -0
  13. package/dist/commands/workflow/phase-orchestrator.js +3 -1
  14. package/dist/index.js +40 -0
  15. package/dist/phases/app-store-generation/__tests__/screenshot-composer.test.js +1 -0
  16. package/dist/phases/app-store-generation/index.js +3 -1
  17. package/dist/phases/app-store-generation/screenshot-composer.js +34 -10
  18. package/dist/phases/branch-planning/index.js +3 -1
  19. package/dist/phases/bug-fixing/analyzer.js +3 -1
  20. package/dist/phases/code-implementation/index.js +3 -1
  21. package/dist/phases/code-refine/index.js +3 -1
  22. package/dist/phases/code-review/__tests__/diff-utils.test.js +11 -11
  23. package/dist/phases/code-review/index.js +3 -1
  24. package/dist/phases/code-testing/analyzer.js +3 -1
  25. package/dist/phases/feature-analysis/index.js +3 -1
  26. package/dist/phases/functional-testing/analyzer.js +3 -1
  27. package/dist/phases/growth-analysis/index.js +3 -1
  28. package/dist/phases/intelligence-analysis/__tests__/orchestration.test.js +12 -12
  29. package/dist/phases/intelligence-analysis/agent.js +2 -0
  30. package/dist/phases/intelligence-analysis/index.js +1 -0
  31. package/dist/phases/intelligence-analysis/prompts.js +11 -1
  32. package/dist/phases/output-contracts.js +1 -0
  33. package/dist/phases/pr-execution/__tests__/file-assigner.test.js +22 -13
  34. package/dist/phases/pr-execution/context.js +4 -2
  35. package/dist/phases/pr-execution/file-assigner.js +1 -0
  36. package/dist/phases/pr-resolve/__tests__/checklist-learner.test.js +11 -11
  37. package/dist/phases/pr-resolve/__tests__/prompts.test.js +12 -12
  38. package/dist/phases/pr-resolve/__tests__/resolve-mapping.test.js +6 -6
  39. package/dist/phases/pr-resolve/__tests__/types.test.js +11 -11
  40. package/dist/phases/pr-resolve/__tests__/workspace.test.js +13 -13
  41. package/dist/phases/pr-resolve/checklist-learner.js +34 -9
  42. package/dist/phases/pr-resolve/index.js +29 -13
  43. package/dist/phases/pr-resolve/prompts.js +2 -1
  44. package/dist/phases/pr-resolve/workspace.d.ts +12 -2
  45. package/dist/phases/pr-resolve/workspace.js +6 -4
  46. package/dist/phases/pr-review/__tests__/prompts.test.js +9 -9
  47. package/dist/phases/pr-review/__tests__/review-comments.test.js +6 -6
  48. package/dist/phases/pr-review/index.js +1 -0
  49. package/dist/phases/pr-shared/__tests__/agent-utils.test.js +17 -17
  50. package/dist/phases/pr-shared/__tests__/context.test.js +12 -12
  51. package/dist/phases/pr-splitting/import-dep-validator.js +14 -6
  52. package/dist/phases/pr-splitting/index.js +3 -1
  53. package/dist/phases/release-sync/__tests__/github.test.d.ts +9 -0
  54. package/dist/phases/release-sync/__tests__/github.test.js +123 -0
  55. package/dist/phases/release-sync/__tests__/snapshot.test.d.ts +8 -0
  56. package/dist/phases/release-sync/__tests__/snapshot.test.js +93 -0
  57. package/dist/phases/release-sync/github.d.ts +54 -0
  58. package/dist/phases/release-sync/github.js +101 -0
  59. package/dist/phases/release-sync/index.d.ts +24 -0
  60. package/dist/phases/release-sync/index.js +147 -0
  61. package/dist/phases/release-sync/snapshot.d.ts +27 -0
  62. package/dist/phases/release-sync/snapshot.js +159 -0
  63. package/dist/phases/smoke-test/__tests__/agent.test.d.ts +4 -0
  64. package/dist/phases/smoke-test/__tests__/agent.test.js +85 -0
  65. package/dist/phases/smoke-test/agent.d.ts +12 -0
  66. package/dist/phases/smoke-test/agent.js +94 -0
  67. package/dist/phases/smoke-test/index.d.ts +22 -0
  68. package/dist/phases/smoke-test/index.js +233 -0
  69. package/dist/phases/smoke-test/prompts.d.ts +15 -0
  70. package/dist/phases/smoke-test/prompts.js +35 -0
  71. package/dist/phases/technical-design/index.js +3 -1
  72. package/dist/phases/test-cases-analysis/index.js +3 -1
  73. package/dist/phases/user-stories-analysis/index.js +3 -1
  74. package/dist/services/phase-hooks/__tests__/hook-executor.test.js +7 -4
  75. package/dist/services/phase-hooks/__tests__/hook-runner.test.js +22 -21
  76. package/dist/services/phase-hooks/hook-executor.js +1 -0
  77. package/dist/services/phase-hooks/plugin-loader.js +3 -0
  78. package/dist/services/video/screenshot-generator.js +8 -2
  79. package/dist/skills/phase/smoke-test/SKILL.md +80 -0
  80. package/dist/utils/json-extract.d.ts +6 -0
  81. package/dist/utils/json-extract.js +44 -0
  82. package/dist/workspace/__tests__/workspace-manager.test.d.ts +7 -0
  83. package/dist/workspace/__tests__/workspace-manager.test.js +52 -0
  84. package/dist/workspace/workspace-manager.d.ts +31 -0
  85. package/dist/workspace/workspace-manager.js +96 -10
  86. package/package.json +1 -1
  87. package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.d.ts +0 -4
  88. package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.js +0 -133
  89. package/dist/services/lifecycle-agent/__tests__/transition-rules.test.d.ts +0 -4
  90. package/dist/services/lifecycle-agent/__tests__/transition-rules.test.js +0 -336
  91. package/dist/services/lifecycle-agent/index.d.ts +0 -24
  92. package/dist/services/lifecycle-agent/index.js +0 -25
  93. package/dist/services/lifecycle-agent/phase-criteria.d.ts +0 -57
  94. package/dist/services/lifecycle-agent/phase-criteria.js +0 -335
  95. package/dist/services/lifecycle-agent/transition-rules.d.ts +0 -60
  96. package/dist/services/lifecycle-agent/transition-rules.js +0 -184
  97. package/dist/services/lifecycle-agent/types.d.ts +0 -190
  98. package/dist/services/lifecycle-agent/types.js +0 -12
@@ -6,7 +6,9 @@ import { executeTestCasesAnalysisQuery, parseAnalysisResult } from './agent.js';
6
6
  import { prepareTestCasesAnalysisContext } from './context.js';
7
7
  import { buildTestCasesAnalysisResult, deleteSpecificTestCases, deleteTestCaseArtifacts, getAllDraftTestCaseIds, resetReadyTestCasesToDraft, saveTestCasesAsDraft, updateTestCasesToReady, } from './outcome.js';
8
8
  import { createTestCasesAnalysisSystemPrompt } from './prompts.js';
9
- export const analyseTestCases = async (options, config, checklistContext) => {
9
+ export const analyseTestCases = async (options, config, checklistContext
10
+ // eslint-disable-next-line complexity
11
+ ) => {
10
12
  const { featureId, verbose } = options;
11
13
  if (verbose) {
12
14
  logInfo(`Starting test cases analysis for feature ID: ${featureId}`);
@@ -6,7 +6,9 @@ import { executeUserStoriesAnalysisQuery, parseAnalysisResult, } from './agent.j
6
6
  import { prepareUserStoriesAnalysisContext } from './context.js';
7
7
  import { buildUserStoriesAnalysisResult, deleteSpecificUserStories, deleteUserStoryArtifacts, getAllDraftUserStoryIds, resetReadyUserStoriesToDraft, saveUserStoriesAsDraft, updateUserStoriesToReady, } from './outcome.js';
8
8
  import { createUserStoriesAnalysisSystemPrompt } from './prompts.js';
9
- export const analyseUserStories = async (options, config, checklistContext) => {
9
+ export const analyseUserStories = async (options, config, checklistContext
10
+ // eslint-disable-next-line complexity
11
+ ) => {
10
12
  const { featureId, verbose } = options;
11
13
  if (verbose) {
12
14
  logInfo(`Starting user stories analysis for feature ID: ${featureId}`);
@@ -163,6 +163,7 @@ void describe('executeHook', () => {
163
163
  ) {
164
164
  // Return a function that returns an async iterable
165
165
  return () => ({
166
+ // eslint-disable-next-line @typescript-eslint/require-await
166
167
  async *[Symbol.asyncIterator]() {
167
168
  for (const msg of messages) {
168
169
  yield msg;
@@ -172,7 +173,7 @@ void describe('executeHook', () => {
172
173
  }
173
174
  function makeDeps(overrides = {}) {
174
175
  return {
175
- loadSkillFile: async () => defaultSkillFile,
176
+ loadSkillFile: () => Promise.resolve(defaultSkillFile),
176
177
  queryFn: mockQuery([
177
178
  {
178
179
  type: 'result',
@@ -184,7 +185,7 @@ void describe('executeHook', () => {
184
185
  };
185
186
  }
186
187
  void it('returns skipped when skill file not found', async () => {
187
- const deps = makeDeps({ loadSkillFile: async () => null });
188
+ const deps = makeDeps({ loadSkillFile: () => Promise.resolve(null) });
188
189
  const result = await executeHook(makeBinding(), makeContext(), false, deps);
189
190
  assert.strictEqual(result.status, 'skipped');
190
191
  assert.ok(result.message.includes('not found'));
@@ -266,7 +267,7 @@ void describe('executeHook', () => {
266
267
  void it('passes correct model and maxTurns from frontmatter', async () => {
267
268
  let capturedOptions = {};
268
269
  const deps = makeDeps({
269
- loadSkillFile: async () => ({
270
+ loadSkillFile: () => Promise.resolve({
270
271
  frontmatter: { model: 'haiku', maxTurns: 5 },
271
272
  body: 'Test prompt',
272
273
  }),
@@ -274,6 +275,7 @@ void describe('executeHook', () => {
274
275
  queryFn: ((opts) => {
275
276
  capturedOptions = opts.options;
276
277
  return {
278
+ // eslint-disable-next-line @typescript-eslint/require-await
277
279
  async *[Symbol.asyncIterator]() {
278
280
  yield {
279
281
  type: 'result',
@@ -292,7 +294,7 @@ void describe('executeHook', () => {
292
294
  void it('uses DEFAULT_MODEL when frontmatter has no model', async () => {
293
295
  let capturedOptions = {};
294
296
  const deps = makeDeps({
295
- loadSkillFile: async () => ({
297
+ loadSkillFile: () => Promise.resolve({
296
298
  frontmatter: {},
297
299
  body: 'Test prompt',
298
300
  }),
@@ -300,6 +302,7 @@ void describe('executeHook', () => {
300
302
  queryFn: ((opts) => {
301
303
  capturedOptions = opts.options;
302
304
  return {
305
+ // eslint-disable-next-line @typescript-eslint/require-await
303
306
  async *[Symbol.asyncIterator]() {
304
307
  yield {
305
308
  type: 'result',
@@ -37,10 +37,10 @@ function makeContext(overrides = {}) {
37
37
  }
38
38
  function makeDeps(overrides = {}) {
39
39
  return {
40
- executeHook: async (binding) => makeResult(binding),
40
+ executeHook: (binding) => Promise.resolve(makeResult(binding)),
41
41
  getCachedBindings: () => null,
42
42
  getBindingsForPhase: () => [],
43
- logHookEvent: async () => { },
43
+ logHookEvent: () => Promise.resolve(),
44
44
  ...overrides,
45
45
  };
46
46
  }
@@ -86,9 +86,9 @@ void describe('runHooksForPhase', () => {
86
86
  const deps = makeDeps({
87
87
  getCachedBindings: () => cached,
88
88
  getBindingsForPhase: () => [binding],
89
- executeHook: async (b) => {
89
+ executeHook: (b) => {
90
90
  executeCalls.push(b);
91
- return makeResult(b);
91
+ return Promise.resolve(makeResult(b));
92
92
  },
93
93
  });
94
94
  const result = await runHooksForPhase(makeContext(), deps);
@@ -109,9 +109,9 @@ void describe('runHooksForPhase', () => {
109
109
  const deps = makeDeps({
110
110
  getCachedBindings: () => cached,
111
111
  getBindingsForPhase: () => [b1, b2],
112
- executeHook: async (b) => {
112
+ executeHook: (b) => {
113
113
  order.push(b.id);
114
- return makeResult(b);
114
+ return Promise.resolve(makeResult(b));
115
115
  },
116
116
  });
117
117
  await runHooksForPhase(makeContext(), deps);
@@ -133,15 +133,15 @@ void describe('runHooksForPhase', () => {
133
133
  const deps = makeDeps({
134
134
  getCachedBindings: () => cached,
135
135
  getBindingsForPhase: () => [b1, b2],
136
- executeHook: async (b) => {
136
+ executeHook: (b) => {
137
137
  executedIds.push(b.id);
138
138
  if (b.id === 'blocker') {
139
- return makeResult(b, {
139
+ return Promise.resolve(makeResult(b, {
140
140
  status: 'error',
141
141
  message: 'Validation failed',
142
- });
142
+ }));
143
143
  }
144
- return makeResult(b);
144
+ return Promise.resolve(makeResult(b));
145
145
  },
146
146
  });
147
147
  const result = await runHooksForPhase(makeContext(), deps);
@@ -165,14 +165,14 @@ void describe('runHooksForPhase', () => {
165
165
  const deps = makeDeps({
166
166
  getCachedBindings: () => cached,
167
167
  getBindingsForPhase: () => [b1, b2],
168
- executeHook: async (b) => {
168
+ executeHook: (b) => {
169
169
  if (b.id === 'warner') {
170
- return makeResult(b, {
170
+ return Promise.resolve(makeResult(b, {
171
171
  status: 'error',
172
172
  message: 'Non-critical issue',
173
- });
173
+ }));
174
174
  }
175
- return makeResult(b);
175
+ return Promise.resolve(makeResult(b));
176
176
  },
177
177
  });
178
178
  const result = await runHooksForPhase(makeContext(), deps);
@@ -192,11 +192,11 @@ void describe('runHooksForPhase', () => {
192
192
  const deps = makeDeps({
193
193
  getCachedBindings: () => cached,
194
194
  getBindingsForPhase: () => [b1, b2],
195
- executeHook: async (b) => {
195
+ executeHook: (b) => {
196
196
  if (b.id === 'skipper') {
197
- return makeResult(b, { status: 'error', message: 'Ignored' });
197
+ return Promise.resolve(makeResult(b, { status: 'error', message: 'Ignored' }));
198
198
  }
199
- return makeResult(b);
199
+ return Promise.resolve(makeResult(b));
200
200
  },
201
201
  });
202
202
  const result = await runHooksForPhase(makeContext(), deps);
@@ -214,8 +214,9 @@ void describe('runHooksForPhase', () => {
214
214
  const deps = makeDeps({
215
215
  getCachedBindings: () => cached,
216
216
  getBindingsForPhase: () => [binding],
217
- logHookEvent: async ({ result: r }) => {
217
+ logHookEvent: ({ result: r }) => {
218
218
  logCalls.push(r.hookId);
219
+ return Promise.resolve();
219
220
  },
220
221
  });
221
222
  await runHooksForPhase(makeContext(), deps);
@@ -231,8 +232,8 @@ void describe('runHooksForPhase', () => {
231
232
  const deps = makeDeps({
232
233
  getCachedBindings: () => cached,
233
234
  getBindingsForPhase: () => [binding],
234
- logHookEvent: async () => {
235
- throw new Error('Logging failed');
235
+ logHookEvent: () => {
236
+ return Promise.reject(new Error('Logging failed'));
236
237
  },
237
238
  });
238
239
  const result = await runHooksForPhase(makeContext(), deps);
@@ -250,7 +251,7 @@ void describe('runHooksForPhase', () => {
250
251
  const deps = makeDeps({
251
252
  getCachedBindings: () => cached,
252
253
  getBindingsForPhase: () => [binding],
253
- executeHook: async (b) => makeResult(b, { status: 'skipped', message: 'Not found' }),
254
+ executeHook: (b) => Promise.resolve(makeResult(b, { status: 'skipped', message: 'Not found' })),
254
255
  });
255
256
  const result = await runHooksForPhase(makeContext(), deps);
256
257
  // 'skipped' is not an error, so on_failure policy should not apply
@@ -94,6 +94,7 @@ export async function executeHook(binding, context, verbose, deps = defaultDeps)
94
94
  if (message.type === 'assistant') {
95
95
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
96
96
  for (const item of (message.message?.content ?? [])) {
97
+ // eslint-disable-next-line max-depth
97
98
  if (item.type === 'text') {
98
99
  resultText += item.text;
99
100
  }
@@ -96,10 +96,12 @@ async function findPluginRootForName(pluginName, cacheDir) {
96
96
  try {
97
97
  const subEntries = await fs.readdir(entryDir, { withFileTypes: true });
98
98
  for (const sub of subEntries) {
99
+ // eslint-disable-next-line max-depth
99
100
  if (!sub.isDirectory() || sub.name !== pluginName) {
100
101
  continue;
101
102
  }
102
103
  const found = await findPluginRoot(path.join(entryDir, sub.name));
104
+ // eslint-disable-next-line max-depth
103
105
  if (found) {
104
106
  return found;
105
107
  }
@@ -116,6 +118,7 @@ async function findPluginRootForName(pluginName, cacheDir) {
116
118
  try {
117
119
  const manifestPath = path.join(root, '.claude-plugin', 'plugin.json');
118
120
  const rawJson = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
121
+ // eslint-disable-next-line max-depth
119
122
  if (typeof rawJson === 'object' &&
120
123
  rawJson !== null &&
121
124
  rawJson.name === pluginName) {
@@ -103,7 +103,10 @@ async function applyDeviceFrame(screenshotPath, scene, browser, verbose) {
103
103
  */
104
104
  export async function captureScreenshots(scenes, options, verbose) {
105
105
  await ensurePlaywright(isPlaywrightAvailable);
106
- const chromium = (await loadChromium());
106
+ const chromium = await loadChromium();
107
+ if (!chromium) {
108
+ throw new Error('Failed to load chromium');
109
+ }
107
110
  const { baseUrl, outputDir, viewportWidth = 1280, viewportHeight = 720, fullPage = false, } = options;
108
111
  // Ensure output directory exists
109
112
  await mkdir(outputDir, { recursive: true });
@@ -194,7 +197,10 @@ export async function captureScreenshots(scenes, options, verbose) {
194
197
  */
195
198
  export async function captureStaticScreenshots(scenes, outputDir, htmlTemplates, verbose) {
196
199
  await ensurePlaywright(isPlaywrightAvailable);
197
- const chromium = (await loadChromium());
200
+ const chromium = await loadChromium();
201
+ if (!chromium) {
202
+ throw new Error('Failed to load chromium');
203
+ }
198
204
  await mkdir(outputDir, { recursive: true });
199
205
  if (verbose) {
200
206
  logInfo(`Rendering ${scenes.length} static HTML scenes`);
@@ -0,0 +1,80 @@
1
+ ---
2
+ description: Generate a focused smoke-test plan for an upcoming product release by diffing the two most recent GitHub releases
3
+ kind: phase
4
+ user-invocable: false
5
+ ---
6
+
7
+ You are a senior QA engineer generating a smoke-test plan for an upcoming release of a product.
8
+
9
+ **Your Role**: Produce a tight, high-signal list of smoke-test cases that verifies the changes between the previous release and the new release still work end-to-end. You are not writing unit tests — you are writing scripted, user-visible checks that a human runs before shipping.
10
+
11
+ **Inputs you will receive**:
12
+
13
+ - Product name + description
14
+ - The new release tag (what we are about to ship)
15
+ - The previous release tag (the comparison baseline)
16
+ - Release notes for the new tag
17
+ - A digest of the code diff: commit list, changed files, and truncated patches
18
+
19
+ <!-- if:hasCodebase -->
20
+
21
+ The product's git repository has been cloned into your working directory and checked out at the new release tag. Use your tools to read the actual source files mentioned in the diff when the patch alone is ambiguous — it's better to open `src/api/foo.ts` and confirm the behavior change than to guess from a partial patch.
22
+
23
+ <!-- endif -->
24
+
25
+ <!-- if:!hasCodebase -->
26
+
27
+ Source files are not available in your working directory for this run. Work strictly from the release notes and the diff digest. When a patch is ambiguous, prefer writing a more general test case over inventing details you cannot verify, and note the uncertainty in the case description.
28
+
29
+ <!-- endif -->
30
+
31
+ ## Method
32
+
33
+ 1. **Read the release notes and diff digest first**. The notes tell you user-facing intent; the diff shows reality. Reconcile them.
34
+ 2. **Cluster the changes** into coherent user-visible areas (e.g. "checkout flow", "admin settings page", "auth callback"). Cases should map to areas, not to individual files.
35
+ 3. **For each cluster, design a case** that:
36
+ - Has a clear starting state, 2–6 steps, and an explicit expected result.
37
+ - Is runnable in < 5 minutes by a human tester without reading the source.
38
+ - Covers the **behavior that changed**, not behavior that was already tested before.
39
+ 4. **Tag `is_critical: true` only** when a failure of the case should block the release. A typical release has 1–4 critical cases, not 10.
40
+ 5. **Skip** purely internal refactors, dependency bumps, and test-only commits unless they could have user-visible effects.
41
+
42
+ ## Output contract
43
+
44
+ Respond with **ONLY** a single JSON object — no prose, no markdown fences:
45
+
46
+ ```
47
+ {
48
+ "summary": "1-3 sentence summary of what changed in this release",
49
+ "test_cases": [
50
+ {
51
+ "name": "short imperative title (<= 120 chars)",
52
+ "description": "Markdown with explicit Steps and Expected result sections",
53
+ "is_critical": true
54
+ }
55
+ ]
56
+ }
57
+ ```
58
+
59
+ Rules:
60
+
61
+ - **4 to 12** test cases. Fewer is better than padded.
62
+ - Every case must tie back to a real change in the diff. Do not invent tests for code that did not change.
63
+ - Names must be unique within the list.
64
+ - Descriptions must use this Markdown structure:
65
+
66
+ ```
67
+ **Steps**
68
+ 1. ...
69
+ 2. ...
70
+
71
+ **Expected result**
72
+ ...
73
+ ```
74
+
75
+ ## Common pitfalls
76
+
77
+ - **Over-testing internal plumbing**: if the diff is an internal rename with no user-visible effect, skip it.
78
+ - **Duplicating existing feature coverage**: you are testing the delta, not the whole product. The regular feature-level test cases already cover baseline behavior.
79
+ - **Vague "verify X works" cases**: give explicit inputs and observable outputs. A tester should not have to think about what "works" means.
80
+ - **All-critical lists**: if everything is critical, nothing is. Reserve critical for cases whose failure justifies holding the release.
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Scan a string for the first balanced, top-level JSON object and return its
3
+ * substring. Handles strings with escaped quotes and nested braces. Returns
4
+ * null if no balanced object is present.
5
+ */
6
+ export declare function findBalancedJsonObject(text: string): string | null;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Scan a string for the first balanced, top-level JSON object and return its
3
+ * substring. Handles strings with escaped quotes and nested braces. Returns
4
+ * null if no balanced object is present.
5
+ */
6
+ export function findBalancedJsonObject(text) {
7
+ const start = text.indexOf('{');
8
+ if (start === -1) {
9
+ return null;
10
+ }
11
+ let depth = 0;
12
+ let inString = false;
13
+ let escaped = false;
14
+ for (let i = start; i < text.length; i++) {
15
+ const ch = text[i];
16
+ if (escaped) {
17
+ escaped = false;
18
+ continue;
19
+ }
20
+ if (inString) {
21
+ if (ch === '\\') {
22
+ escaped = true;
23
+ }
24
+ else if (ch === '"') {
25
+ inString = false;
26
+ }
27
+ continue;
28
+ }
29
+ if (ch === '"') {
30
+ inString = true;
31
+ continue;
32
+ }
33
+ if (ch === '{') {
34
+ depth++;
35
+ }
36
+ else if (ch === '}') {
37
+ depth--;
38
+ if (depth === 0) {
39
+ return text.slice(start, i + 1);
40
+ }
41
+ }
42
+ }
43
+ return null;
44
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Unit tests for the pure helpers inside workspace-manager.
3
+ *
4
+ * syncRepoToRef / cloneFeatureRepo shell out to git and are exercised
5
+ * end-to-end; only the validator is cheap to unit-test here.
6
+ */
7
+ export {};
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Unit tests for the pure helpers inside workspace-manager.
3
+ *
4
+ * syncRepoToRef / cloneFeatureRepo shell out to git and are exercised
5
+ * end-to-end; only the validator is cheap to unit-test here.
6
+ */
7
+ import assert from 'node:assert';
8
+ import { describe, it } from 'node:test';
9
+ import { isSafeGitRef } from '../workspace-manager.js';
10
+ void describe('isSafeGitRef', () => {
11
+ void it('accepts common release / branch names', () => {
12
+ assert.strictEqual(isSafeGitRef('main'), true);
13
+ assert.strictEqual(isSafeGitRef('develop'), true);
14
+ assert.strictEqual(isSafeGitRef('v1.2.3'), true);
15
+ assert.strictEqual(isSafeGitRef('2024.01.15'), true);
16
+ assert.strictEqual(isSafeGitRef('release/2.0'), true);
17
+ assert.strictEqual(isSafeGitRef('v1.0.0-rc.1'), true);
18
+ assert.strictEqual(isSafeGitRef('v2@stable'), true);
19
+ assert.strictEqual(isSafeGitRef('feature/user-auth_v2'), true);
20
+ });
21
+ void it('rejects empty / overlong input', () => {
22
+ assert.strictEqual(isSafeGitRef(''), false);
23
+ assert.strictEqual(isSafeGitRef('x'.repeat(101)), false);
24
+ });
25
+ void it('rejects whitespace', () => {
26
+ assert.strictEqual(isSafeGitRef('v 1.0'), false);
27
+ assert.strictEqual(isSafeGitRef('main\n'), false);
28
+ assert.strictEqual(isSafeGitRef('\tmain'), false);
29
+ });
30
+ void it('rejects shell metacharacters', () => {
31
+ assert.strictEqual(isSafeGitRef('v1;rm -rf /'), false);
32
+ assert.strictEqual(isSafeGitRef('v1$PATH'), false);
33
+ assert.strictEqual(isSafeGitRef('v1`id`'), false);
34
+ assert.strictEqual(isSafeGitRef('v1|less'), false);
35
+ assert.strictEqual(isSafeGitRef('v1>out'), false);
36
+ assert.strictEqual(isSafeGitRef('v1*'), false);
37
+ });
38
+ void it('rejects refs that would confuse git itself', () => {
39
+ assert.strictEqual(isSafeGitRef('.hidden'), false);
40
+ assert.strictEqual(isSafeGitRef('-foo'), false);
41
+ assert.strictEqual(isSafeGitRef('v2..rc'), false);
42
+ assert.strictEqual(isSafeGitRef('HEAD@{1}'), false);
43
+ });
44
+ void it('rejects non-string input defensively', () => {
45
+ // @ts-expect-error testing runtime guard
46
+ assert.strictEqual(isSafeGitRef(null), false);
47
+ // @ts-expect-error testing runtime guard
48
+ assert.strictEqual(isSafeGitRef(undefined), false);
49
+ // @ts-expect-error testing runtime guard
50
+ assert.strictEqual(isSafeGitRef(123), false);
51
+ });
52
+ });
@@ -52,6 +52,37 @@ export declare function featureRepoExists(workspaceRoot: string, featureId: stri
52
52
  * @returns FeatureRepo with the path and clone status
53
53
  */
54
54
  export declare function cloneFeatureRepo(workspaceRoot: string, featureId: string, owner: string, repo: string, token: string): FeatureRepo;
55
+ /**
56
+ * Loose validator for a git ref name (tag or branch) coming from callers
57
+ * that pass data originating from external sources (GitHub API, AI output).
58
+ *
59
+ * Rules match `git check-ref-format` plus a length cap. Rejecting bad
60
+ * input early keeps it out of execFileSync argv.
61
+ */
62
+ export declare function isSafeGitRef(ref: string): boolean;
63
+ /**
64
+ * Sync a cloned feature repo to a specific git ref — a tag or a branch.
65
+ *
66
+ * Behaviour:
67
+ * - `git fetch origin --tags --prune` brings in new tags (release tags!) and
68
+ * drops deleted remote branches, using the installation-token credential
69
+ * helper so private repos keep working.
70
+ * - Before switching refs, `git reset --hard HEAD` + `git clean -fd` drops
71
+ * tracked-file edits and untracked files left by a prior run. We skip the
72
+ * `-x` flag so `.gitignore`d content (node_modules, target, dist) survives
73
+ * between runs — matches the convention in pull-request/creator.ts and
74
+ * avoids an expensive reinstall every sync.
75
+ * - For a tag, detached-HEAD checkout of that tag.
76
+ * - For a branch, `checkout -B <branch> origin/<branch>` — force-updates
77
+ * the local branch to the remote tip in one step, which also works on
78
+ * the first sync when the local branch doesn't exist yet.
79
+ *
80
+ * This workspace is edsger-managed; callers should not put local work here.
81
+ */
82
+ export declare function syncRepoToRef(repoPath: string, ref: {
83
+ tag?: string;
84
+ branch?: string;
85
+ }, token: string): void;
55
86
  /**
56
87
  * Set up a feature's repo for work (install deps, etc.)
57
88
  * This is called after cloning or reusing a repo
@@ -10,6 +10,7 @@
10
10
  import { execFileSync, execSync } from 'child_process';
11
11
  import { existsSync, mkdirSync } from 'fs';
12
12
  import { basename, join } from 'path';
13
+ import { buildCredentialArgs } from '../utils/git-push.js';
13
14
  import { logError, logInfo, logSuccess, logWarning } from '../utils/logger.js';
14
15
  const WORKSPACE_DIR_NAME = 'edsger';
15
16
  /**
@@ -69,16 +70,10 @@ export function featureRepoExists(workspaceRoot, featureId) {
69
70
  export function cloneFeatureRepo(workspaceRoot, featureId, owner, repo, token) {
70
71
  const repoPath = getFeatureRepoPath(workspaceRoot, featureId);
71
72
  const repoUrl = `https://github.com/${owner}/${repo}.git`;
72
- // Configure git to use token via credential helper (avoids token in URL / process list)
73
- // First clear any existing credential helpers (e.g. osxkeychain) with an empty value,
74
- // then set our custom helper. This ensures the token is used instead of stale keychain creds.
75
- const credentialHelper = `!f() { echo "username=x-access-token"; echo "password=${token}"; }; f`;
76
- const gitCredentialArgs = [
77
- '-c',
78
- 'credential.helper=',
79
- '-c',
80
- `credential.helper=${credentialHelper}`,
81
- ];
73
+ // Use the shared credential helper builder. Passes the installation
74
+ // token via `git -c credential.helper=...` so it stays out of the
75
+ // remote URL and the process list.
76
+ const gitCredentialArgs = buildCredentialArgs(token);
82
77
  // Check if already cloned
83
78
  if (existsSync(join(repoPath, '.git'))) {
84
79
  logInfo(`Reusing existing repo for feature ${featureId}`);
@@ -121,6 +116,97 @@ export function cloneFeatureRepo(workspaceRoot, featureId, owner, repo, token) {
121
116
  throw error;
122
117
  }
123
118
  }
119
+ /**
120
+ * Loose validator for a git ref name (tag or branch) coming from callers
121
+ * that pass data originating from external sources (GitHub API, AI output).
122
+ *
123
+ * Rules match `git check-ref-format` plus a length cap. Rejecting bad
124
+ * input early keeps it out of execFileSync argv.
125
+ */
126
+ export function isSafeGitRef(ref) {
127
+ if (typeof ref !== 'string') {
128
+ return false;
129
+ }
130
+ if (ref.length === 0 || ref.length > 100) {
131
+ return false;
132
+ }
133
+ if (/\s/.test(ref)) {
134
+ return false;
135
+ }
136
+ if (/^[-.]/.test(ref)) {
137
+ return false;
138
+ }
139
+ if (ref.includes('..') || ref.includes('@{')) {
140
+ return false;
141
+ }
142
+ return /^[A-Za-z0-9._\-+/@]+$/.test(ref);
143
+ }
144
+ /**
145
+ * Sync a cloned feature repo to a specific git ref — a tag or a branch.
146
+ *
147
+ * Behaviour:
148
+ * - `git fetch origin --tags --prune` brings in new tags (release tags!) and
149
+ * drops deleted remote branches, using the installation-token credential
150
+ * helper so private repos keep working.
151
+ * - Before switching refs, `git reset --hard HEAD` + `git clean -fd` drops
152
+ * tracked-file edits and untracked files left by a prior run. We skip the
153
+ * `-x` flag so `.gitignore`d content (node_modules, target, dist) survives
154
+ * between runs — matches the convention in pull-request/creator.ts and
155
+ * avoids an expensive reinstall every sync.
156
+ * - For a tag, detached-HEAD checkout of that tag.
157
+ * - For a branch, `checkout -B <branch> origin/<branch>` — force-updates
158
+ * the local branch to the remote tip in one step, which also works on
159
+ * the first sync when the local branch doesn't exist yet.
160
+ *
161
+ * This workspace is edsger-managed; callers should not put local work here.
162
+ */
163
+ export function syncRepoToRef(repoPath, ref, token) {
164
+ if (!existsSync(join(repoPath, '.git'))) {
165
+ throw new Error(`Not a git repo: ${repoPath}`);
166
+ }
167
+ if (!ref.tag && !ref.branch) {
168
+ throw new Error('syncRepoToRef requires either tag or branch');
169
+ }
170
+ if (ref.tag && !isSafeGitRef(ref.tag)) {
171
+ throw new Error(`Unsafe tag ref: ${JSON.stringify(ref.tag)}`);
172
+ }
173
+ if (ref.branch && !isSafeGitRef(ref.branch)) {
174
+ throw new Error(`Unsafe branch ref: ${JSON.stringify(ref.branch)}`);
175
+ }
176
+ const creds = buildCredentialArgs(token);
177
+ try {
178
+ execFileSync('git', [...creds, 'fetch', 'origin', '--tags', '--prune'], { cwd: repoPath, stdio: 'pipe' });
179
+ }
180
+ catch {
181
+ logWarning('git fetch failed during sync; working tree may be stale');
182
+ }
183
+ // Discard any residual state from a previous run before switching refs.
184
+ // Use `-fd` (not `-fdx`) to preserve .gitignore'd build output / deps.
185
+ try {
186
+ execFileSync('git', ['reset', '--hard', 'HEAD'], {
187
+ cwd: repoPath,
188
+ stdio: 'pipe',
189
+ });
190
+ execFileSync('git', ['clean', '-fd'], { cwd: repoPath, stdio: 'pipe' });
191
+ }
192
+ catch {
193
+ // Non-fatal; subsequent checkout will surface any real issue.
194
+ }
195
+ try {
196
+ if (ref.tag) {
197
+ execFileSync('git', ['-c', 'advice.detachedHead=false', 'checkout', `refs/tags/${ref.tag}`], { cwd: repoPath, stdio: 'pipe' });
198
+ logInfo(`Checked out tag ${ref.tag}`);
199
+ }
200
+ else if (ref.branch) {
201
+ // Create-or-reset local branch to origin tip.
202
+ execFileSync('git', ['checkout', '-B', ref.branch, `origin/${ref.branch}`], { cwd: repoPath, stdio: 'pipe' });
203
+ logInfo(`Checked out branch ${ref.branch} at origin/${ref.branch}`);
204
+ }
205
+ }
206
+ catch (error) {
207
+ throw new Error(`Failed to checkout ${ref.tag ?? ref.branch}: ${error instanceof Error ? error.message : String(error)}`);
208
+ }
209
+ }
124
210
  /**
125
211
  * Set up a feature's repo for work (install deps, etc.)
126
212
  * This is called after cloning or reusing a repo
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edsger",
3
- "version": "0.42.1",
3
+ "version": "0.44.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "edsger": "dist/index.js"
@@ -1,4 +0,0 @@
1
- /**
2
- * Unit tests for phase quality criteria definitions
3
- */
4
- export {};