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.
- package/.claude/settings.local.json +23 -3
- package/.env.local +12 -0
- package/dist/api/release-test-cases.d.ts +7 -0
- package/dist/api/release-test-cases.js +21 -0
- package/dist/api/releases.d.ts +41 -0
- package/dist/api/releases.js +31 -0
- package/dist/api/web-deploy.d.ts +8 -1
- package/dist/api/web-deploy.js +2 -1
- package/dist/commands/release-sync/index.d.ts +5 -0
- package/dist/commands/release-sync/index.js +38 -0
- package/dist/commands/smoke-test/index.d.ts +5 -0
- package/dist/commands/smoke-test/index.js +40 -0
- package/dist/commands/workflow/phase-orchestrator.js +3 -1
- package/dist/index.js +40 -0
- package/dist/phases/app-store-generation/__tests__/screenshot-composer.test.js +1 -0
- package/dist/phases/app-store-generation/index.js +3 -1
- package/dist/phases/app-store-generation/screenshot-composer.js +34 -10
- package/dist/phases/branch-planning/index.js +3 -1
- package/dist/phases/bug-fixing/analyzer.js +3 -1
- package/dist/phases/code-implementation/index.js +3 -1
- package/dist/phases/code-refine/index.js +3 -1
- package/dist/phases/code-review/__tests__/diff-utils.test.js +11 -11
- package/dist/phases/code-review/index.js +3 -1
- package/dist/phases/code-testing/analyzer.js +3 -1
- package/dist/phases/feature-analysis/index.js +3 -1
- package/dist/phases/functional-testing/analyzer.js +3 -1
- package/dist/phases/growth-analysis/index.js +3 -1
- package/dist/phases/intelligence-analysis/__tests__/orchestration.test.js +12 -12
- package/dist/phases/intelligence-analysis/agent.js +2 -0
- package/dist/phases/intelligence-analysis/index.js +1 -0
- package/dist/phases/intelligence-analysis/prompts.js +11 -1
- package/dist/phases/output-contracts.js +1 -0
- package/dist/phases/pr-execution/__tests__/file-assigner.test.js +22 -13
- package/dist/phases/pr-execution/context.js +4 -2
- package/dist/phases/pr-execution/file-assigner.js +1 -0
- package/dist/phases/pr-resolve/__tests__/checklist-learner.test.js +11 -11
- package/dist/phases/pr-resolve/__tests__/prompts.test.js +12 -12
- package/dist/phases/pr-resolve/__tests__/resolve-mapping.test.js +6 -6
- package/dist/phases/pr-resolve/__tests__/types.test.js +11 -11
- package/dist/phases/pr-resolve/__tests__/workspace.test.js +13 -13
- package/dist/phases/pr-resolve/checklist-learner.js +34 -9
- package/dist/phases/pr-resolve/index.js +29 -13
- package/dist/phases/pr-resolve/prompts.js +2 -1
- package/dist/phases/pr-resolve/workspace.d.ts +12 -2
- package/dist/phases/pr-resolve/workspace.js +6 -4
- package/dist/phases/pr-review/__tests__/prompts.test.js +9 -9
- package/dist/phases/pr-review/__tests__/review-comments.test.js +6 -6
- package/dist/phases/pr-review/index.js +1 -0
- package/dist/phases/pr-shared/__tests__/agent-utils.test.js +17 -17
- package/dist/phases/pr-shared/__tests__/context.test.js +12 -12
- package/dist/phases/pr-splitting/import-dep-validator.js +14 -6
- package/dist/phases/pr-splitting/index.js +3 -1
- package/dist/phases/release-sync/__tests__/github.test.d.ts +9 -0
- package/dist/phases/release-sync/__tests__/github.test.js +123 -0
- package/dist/phases/release-sync/__tests__/snapshot.test.d.ts +8 -0
- package/dist/phases/release-sync/__tests__/snapshot.test.js +93 -0
- package/dist/phases/release-sync/github.d.ts +54 -0
- package/dist/phases/release-sync/github.js +101 -0
- package/dist/phases/release-sync/index.d.ts +24 -0
- package/dist/phases/release-sync/index.js +147 -0
- package/dist/phases/release-sync/snapshot.d.ts +27 -0
- package/dist/phases/release-sync/snapshot.js +159 -0
- package/dist/phases/smoke-test/__tests__/agent.test.d.ts +4 -0
- package/dist/phases/smoke-test/__tests__/agent.test.js +85 -0
- package/dist/phases/smoke-test/agent.d.ts +12 -0
- package/dist/phases/smoke-test/agent.js +94 -0
- package/dist/phases/smoke-test/index.d.ts +22 -0
- package/dist/phases/smoke-test/index.js +233 -0
- package/dist/phases/smoke-test/prompts.d.ts +15 -0
- package/dist/phases/smoke-test/prompts.js +35 -0
- package/dist/phases/technical-design/index.js +3 -1
- package/dist/phases/test-cases-analysis/index.js +3 -1
- package/dist/phases/user-stories-analysis/index.js +3 -1
- package/dist/services/phase-hooks/__tests__/hook-executor.test.js +7 -4
- package/dist/services/phase-hooks/__tests__/hook-runner.test.js +22 -21
- package/dist/services/phase-hooks/hook-executor.js +1 -0
- package/dist/services/phase-hooks/plugin-loader.js +3 -0
- package/dist/services/video/screenshot-generator.js +8 -2
- package/dist/skills/phase/smoke-test/SKILL.md +80 -0
- package/dist/utils/json-extract.d.ts +6 -0
- package/dist/utils/json-extract.js +44 -0
- package/dist/workspace/__tests__/workspace-manager.test.d.ts +7 -0
- package/dist/workspace/__tests__/workspace-manager.test.js +52 -0
- package/dist/workspace/workspace-manager.d.ts +31 -0
- package/dist/workspace/workspace-manager.js +96 -10
- package/package.json +1 -1
- package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.d.ts +0 -4
- package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.js +0 -133
- package/dist/services/lifecycle-agent/__tests__/transition-rules.test.d.ts +0 -4
- package/dist/services/lifecycle-agent/__tests__/transition-rules.test.js +0 -336
- package/dist/services/lifecycle-agent/index.d.ts +0 -24
- package/dist/services/lifecycle-agent/index.js +0 -25
- package/dist/services/lifecycle-agent/phase-criteria.d.ts +0 -57
- package/dist/services/lifecycle-agent/phase-criteria.js +0 -335
- package/dist/services/lifecycle-agent/transition-rules.d.ts +0 -60
- package/dist/services/lifecycle-agent/transition-rules.js +0 -184
- package/dist/services/lifecycle-agent/types.d.ts +0 -190
- 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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
40
|
+
executeHook: (binding) => Promise.resolve(makeResult(binding)),
|
|
41
41
|
getCachedBindings: () => null,
|
|
42
42
|
getBindingsForPhase: () => [],
|
|
43
|
-
logHookEvent:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
235
|
-
|
|
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:
|
|
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 =
|
|
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 =
|
|
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,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
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
const
|
|
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